Skip to content

Commit

Permalink
Add ability to evaluate current and outer block (#138)
Browse files Browse the repository at this point in the history
  • Loading branch information
abogoyavlensky authored and avli committed Nov 9, 2019
1 parent 90ddc46 commit 429899b
Show file tree
Hide file tree
Showing 5 changed files with 333 additions and 8 deletions.
10 changes: 7 additions & 3 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,22 @@
"args": ["--extensionDevelopmentPath=${workspaceRoot}" ],
"stopOnEntry": false,
"sourceMaps": true,
"outDir": "${workspaceRoot}/out/src",
"outFiles": ["${workspaceRoot}/out/src/**/*.js"],
"preLaunchTask": "npm"
},
{
"name": "Launch Tests",
"type": "extensionHost",
"request": "launch",
"runtimeExecutable": "${execPath}",
"args": ["--extensionDevelopmentPath=${workspaceRoot}", "--extensionTestsPath=${workspaceRoot}/out/test" ],
"args": [
"--disable-extensions",
"--extensionDevelopmentPath=${workspaceRoot}",
"--extensionTestsPath=${workspaceRoot}/out/test"
],
"stopOnEntry": false,
"sourceMaps": true,
"outDir": "${workspaceRoot}/out/test",
"outFiles": ["${workspaceRoot}/out/test/**/*.js"],
"preLaunchTask": "npm"
}
]
Expand Down
158 changes: 156 additions & 2 deletions src/cljParser.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import * as vscode from 'vscode';

interface ExpressionInfo {
functionName: string;
parameterPosition: number;
Expand All @@ -13,16 +15,18 @@ const CLJ_TEXT_ESCAPE = `\\`;
const CLJ_COMMENT_DELIMITER = `;`;
const R_CLJ_WHITE_SPACE = /\s|,/;
const R_CLJ_OPERATOR_DELIMITERS = /\s|,|\(|{|\[/;
const OPEN_CLJ_BLOCK_BRACKET = `(`;
const CLOSE_CLJ_BLOCK_BRACKET = `)`;

/** { close_char open_char } */
const CLJ_EXPRESSION_DELIMITERS: Map<string, string> = new Map<string, string>([
[`}`, `{`],
[`)`, `(`],
[CLOSE_CLJ_BLOCK_BRACKET, OPEN_CLJ_BLOCK_BRACKET],
[`]`, `[`],
[CLJ_TEXT_DELIMITER, CLJ_TEXT_DELIMITER],
]);

const getExpressionInfo = (text: string): ExpressionInfo | undefined => {
const getExpressionInfo = (text: string): ExpressionInfo | undefined => {
text = removeCljComments(text);
const relativeExpressionInfo = getRelativeExpressionInfo(text);
if (!relativeExpressionInfo)
Expand Down Expand Up @@ -135,8 +139,158 @@ const getNamespace = (text: string): string => {
return m ? m[1] : 'user';
};

/** A range of numbers between `start` and `end`.
* The `end` index is not included and `end` could be before `start`.
* Whereas default `vscode.Range` requires that `start` should be
* before or equal than `end`. */
const range = (start: number, end: number): Array<number> => {
if (start < end) {
const length = end - start;
return Array.from(Array(length), (_, i) => start + i);
} else {
const length = start - end;
return Array.from(Array(length), (_, i) => start - i);
}
}

const findNearestBracket = (
editor: vscode.TextEditor,
current: vscode.Position,
bracket: string): vscode.Position | undefined => {

const isBackward = bracket == OPEN_CLJ_BLOCK_BRACKET;
// "open" and "close" brackets as keys related to search direction
let openBracket = OPEN_CLJ_BLOCK_BRACKET,
closeBracket = CLOSE_CLJ_BLOCK_BRACKET;
if (isBackward) {
[closeBracket, openBracket] = [openBracket, closeBracket]
};

let bracketStack: string[] = [],
// get begin of text if we are searching `(` and end of text otherwise
lastLine = isBackward ? -1 : editor.document.lineCount,
lineRange = range(current.line, lastLine);

for (var line of lineRange) {
const textLine = editor.document.lineAt(line);
if (textLine.isEmptyOrWhitespace) continue;

// get line and strip clj comments
const firstChar = textLine.firstNonWhitespaceCharacterIndex,
strippedLine = removeCljComments(textLine.text);
let startColumn = firstChar,
endColumn = strippedLine.length;
if (isBackward) {
// dec both as `range` doesn't include an end edge
[startColumn, endColumn] = [endColumn - 1, startColumn - 1];
}

// select block if cursor right after the closed bracket or right before opening
if (current.line == line) {
// get current current char index if it is first iteration of loop
let currentColumn = current.character;
// set current position as start
if (isBackward) {
if (currentColumn <= endColumn) continue;
startColumn = currentColumn;
if (strippedLine[startColumn - 1] == CLOSE_CLJ_BLOCK_BRACKET) {
startColumn = startColumn - 2;
} else if (strippedLine[startColumn] == CLOSE_CLJ_BLOCK_BRACKET) {
startColumn--;
};
} else if (currentColumn <= endColumn) {
// forward direction
startColumn = currentColumn;
if (strippedLine[startColumn - 1] == CLOSE_CLJ_BLOCK_BRACKET) {
return new vscode.Position(line, startColumn);
} else if (strippedLine[startColumn] == OPEN_CLJ_BLOCK_BRACKET) {
startColumn++;
};
}
}

// search nearest bracket
for (var column of range(startColumn, endColumn)) {
const char = strippedLine[column];
if (!bracketStack.length && char == bracket) {
// inc column if `char` is a `)` to get correct selection
if (!isBackward) column++;
return new vscode.Position(line, column);
} else if (char == openBracket) {
bracketStack.push(char);
// check if inner block is closing
} else if (char == closeBracket && bracketStack.length > 0) {
bracketStack.pop();
};
};
}
};


const getCurrentBlock = (
editor: vscode.TextEditor,
left?: vscode.Position,
right?: vscode.Position): vscode.Selection | undefined => {

if (!left || !right) {
left = right = editor.selection.active;
};
const prevBracket = findNearestBracket(editor, left, OPEN_CLJ_BLOCK_BRACKET);
if (!prevBracket) return;

const nextBracket = findNearestBracket(editor, right, CLOSE_CLJ_BLOCK_BRACKET);
if (nextBracket) {
return new vscode.Selection(prevBracket, nextBracket);
}
};

const getOuterBlock = (
editor: vscode.TextEditor,
left?: vscode.Position,
right?: vscode.Position,
prevBlock?: vscode.Selection): vscode.Selection | undefined => {

if (!left || !right) {
left = right = editor.selection.active;
};

const nextBlock = getCurrentBlock(editor, left, right);

if (nextBlock) {
// calculate left position one step before
if (nextBlock.anchor.character > 0) {
left = new vscode.Position(nextBlock.anchor.line,
nextBlock.anchor.character - 1);
} else if (nextBlock.anchor.line > 0) {
const line = nextBlock.anchor.line - 1;
left = editor.document.lineAt(line).range.end;
} else {
return new vscode.Selection(nextBlock.anchor, nextBlock.active);
}

// calculate right position one step after
const lineLength = editor.document.lineAt(nextBlock.active.line).text.length;
if (nextBlock.active.character < lineLength) {
right = new vscode.Position(nextBlock.active.line,
nextBlock.active.character + 1);
} else if (nextBlock.active.line < editor.document.lineCount - 1) {
right = new vscode.Position(nextBlock.active.line + 1, 0);
} else {
return new vscode.Selection(nextBlock.anchor, nextBlock.active);
};

// try to find next outer block
return getOuterBlock(editor, left, right, nextBlock);
} else if (right != left) {
return prevBlock;
};
}

export const cljParser = {
R_CLJ_WHITE_SPACE,
getExpressionInfo,
getNamespace,
getCurrentBlock,
getOuterBlock,
};
31 changes: 30 additions & 1 deletion src/clojureEval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import { cljParser } from './cljParser';
import { nreplClient } from './nreplClient';
import { TestListener } from './testRunner';

const HIGHLIGHTING_TIMEOUT = 350;
const BLOCK_DECORATION_TYPE = vscode.window.createTextEditorDecorationType({
backgroundColor: { id: 'editor.findMatchHighlightBackground' }
});

export function clojureEval(outputChannel: vscode.OutputChannel): void {
evaluate(outputChannel, false);
}
Expand Down Expand Up @@ -135,6 +140,17 @@ export function runAllTests(outputChannel: vscode.OutputChannel, listener: TestL
runTests(outputChannel, listener);
}

const highlightSelection = (editor: vscode.TextEditor, selection: vscode.Selection) => {
let selectionRange = new vscode.Range(selection.start, selection.end);
// setup highlighting of evaluated block
editor.setDecorations(BLOCK_DECORATION_TYPE, [selectionRange])
// stop highlighting of block after timeout
setTimeout(() => {
editor.setDecorations(BLOCK_DECORATION_TYPE, [])
},
HIGHLIGHTING_TIMEOUT);
};

function evaluate(outputChannel: vscode.OutputChannel, showResults: boolean): void {
if (!cljConnection.isConnected()) {
vscode.window.showWarningMessage('You should connect to nREPL first to evaluate code.');
Expand All @@ -144,7 +160,20 @@ function evaluate(outputChannel: vscode.OutputChannel, showResults: boolean): vo
const editor = vscode.window.activeTextEditor;

if (!editor) return;
const selection = editor.selection;

// select and highlight appropriate block if selection is empty
let blockSelection: vscode.Selection | undefined;
if (editor.selection.isEmpty) {
blockSelection = showResults ? cljParser.getCurrentBlock(editor) : cljParser.getOuterBlock(editor);
if (blockSelection) {
highlightSelection(editor, blockSelection);
console.log("eval:\n", editor.document.getText(blockSelection));
} else {
console.log("eval:", "Whole file");
}
}

const selection = blockSelection || editor.selection;
let text = editor.document.getText();
if (!selection.isEmpty) {
const ns: string = cljParser.getNamespace(text);
Expand Down
105 changes: 103 additions & 2 deletions test/cljParser.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import * as assert from 'assert';
import {cljParser} from '../src/cljParser';
import * as path from 'path';
import * as vscode from 'vscode';
import { cljParser } from '../src/cljParser';

suite('cljParser', () => {
const testFolderLocation = '/../../test/documents/';

suite('cljParser.getNamespace', () => {
let cases = [
['user', ''],
['foo', '(ns foo)'],
Expand Down Expand Up @@ -29,3 +33,100 @@ suite('cljParser', () => {
});
}
});

suite('cljParser.getCurrentBlock', () => {
// title, line, character, expected
let cases: [string, number, number, string | undefined][] = [
['Position on the same line', 16, 9, '(prn "test")'],
['Position at the middle of multiline block', 22, 5,
'(->> numbers\n' +
' (map inc)\n' +
' (prn))'],
['Ignore inline clj comments', 19, 16,
'(let [numbers [1 2 3]\n' +
' VAL (atom {:some "DATA"})]\n' +
' ; missing left bracket prn "hided text") in comment\n' +
' (prn [@VAL])\n' +
' (->> numbers\n' +
' (map inc)\n' +
' (prn)))'],
['Comment form will be evaluated', 27, 14, '(prn "COMMENT")'],
['Eval only outside bracket from right side', 23, 11, '(prn)'],
['Eval only outside bracket from left side', 28, 5,
'(comp #(str % "!") name)'],
['Eval only round bracket block', 20, 12, '(prn [@VAL])'],
['Eval when only inside of the block', 24, 0, undefined],
['Begin of file', 0, 0,
'(ns user\n' +
' (:require [clojure.tools.namespace.repl :refer [set-refresh-dirs]]\n' +
' [reloaded.repl :refer [system stop go reset]]\n' +
' [myproject.config :refer [config]]\n' +
' [myproject.core :refer [new-system]]))'],
['End of file', 37, 0, undefined]
];
testBlockSelection('Eval current block', cljParser.getCurrentBlock, 'evalBlock.clj', cases);
});

suite('cljParser.getOuterBlock', () => {
// title, line, character, expected
let cases: [string, number, number, string | undefined][] = [
['Get outermost block of function definition', 11, 20,
'(defn new-system-dev\n' +
' []\n' +
' (let [_ 1]\n' +
' (new-system (config))))'],
['Outer block from outside left bracket', 8, 0,
'(defn new-system-dev\n' +
' []\n' +
' (let [_ 1]\n' +
' (new-system (config))))'],
['Outer block from outside right bracket', 28, 38,
'(comment\n' +
' (do\n' +
' #_(prn "COMMENT")\n' +
' ((comp #(str % "!") name) :test)))'],
['Outer block not found', 24, 0, undefined],
['Begin of file', 0, 0,
'(ns user\n' +
' (:require [clojure.tools.namespace.repl :refer [set-refresh-dirs]]\n' +
' [reloaded.repl :refer [system stop go reset]]\n' +
' [myproject.config :refer [config]]\n' +
' [myproject.core :refer [new-system]]))'],
['End of file', 37, 0, undefined],
];
testBlockSelection('Eval outer block', cljParser.getOuterBlock, 'evalBlock.clj', cases);
});

function testBlockSelection(
title: string,
getBlockFn: (editor: vscode.TextEditor) => vscode.Selection | undefined,
fileName: string,
cases: [string, number, number, string | undefined][]) {

test(title, async () => {
const editor = await getEditor(fileName);

for (let [title, line, character, expected] of cases) {
const currentPosition = new vscode.Position(line, character);
editor.selection = new vscode.Selection(currentPosition, currentPosition);
const blockSelection = getBlockFn(editor),
text = blockSelection ? editor.document.getText(blockSelection) : blockSelection;
assert.equal(text, expected, title)
};
vscode.commands.executeCommand('workbench.action.closeActiveEditor');
});
};

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
};

async function getEditor(fileName: string): Promise<vscode.TextEditor> {
const uri = vscode.Uri.file(path.join(__dirname + testFolderLocation + fileName)),
document = await vscode.workspace.openTextDocument(uri),
editor = await vscode.window.showTextDocument(document);
await sleep(600);
return editor;
};
Loading

0 comments on commit 429899b

Please sign in to comment.