diff --git a/package.json b/package.json index ffb951e..822333e 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,10 @@ { "name": "Alessandro Decina", "email": "alessandro.d@gmail.com" + }, + { + "name": "Barak Liato", + "email": "barakliato@gmail.com" } ], "license": "MIT", @@ -52,7 +56,8 @@ "Other" ], "activationEvents": [ - "onLanguage:clojure" + "onCommand:clojureVSCode.startNRepl", + "onCommand:clojureVSCode.manuallyConnectToNRepl" ], "main": "./out/src/clojureMain", "contributes": { @@ -80,6 +85,14 @@ { "command": "clojureVSCode.formatFile", "title": "Clojure: Format file or selection" + }, + { + "command": "clojureVSCode.reloadNamespace", + "title": "reload the current namespace" + }, + { + "command": "clojureVSCode.searchSymbol", + "title": "Try to find the symbol and add its namespace to the decleration" } ], "configuration": { @@ -95,6 +108,16 @@ "type": "boolean", "default": false, "description": "Format the code on save." + }, + "clojureVSCode.alertOnEval": { + "type": "boolean", + "default": true, + "description": "show pop up alert on eval." + }, + "clojureVSCode.autoReloadNamespaceOnSave": { + "type": "boolean", + "default": true, + "description": "auto reload the file upon save." } } } diff --git a/src/cljConnection.ts b/src/cljConnection.ts index b1ff8ec..9475016 100644 --- a/src/cljConnection.ts +++ b/src/cljConnection.ts @@ -225,4 +225,4 @@ export const cljConnection = { startNRepl, disconnect, sessionForFilename -}; +}; \ No newline at end of file diff --git a/src/cljParser.ts b/src/cljParser.ts index 77936de..2110507 100644 --- a/src/cljParser.ts +++ b/src/cljParser.ts @@ -1,3 +1,5 @@ +import { TextDocument, Range, Position, CompletionList } from "vscode"; + interface ExpressionInfo { functionName: string; parameterPosition: number; @@ -135,8 +137,40 @@ const getNamespace = (text: string): string => { return m ? m[1] : 'user'; }; +//assume the position is before the block directly +const getDirectlyBeforeBlockRange = (editor: TextDocument, line: number, column: number): Range => { + const lastLineNumber = editor.lineCount - 1; + const lastLine = editor.lineAt(lastLineNumber); + const range = new Range(line, column, lastLineNumber, lastLine.range.end.character); + const text = editor.getText(range); + + let count = 0; + let endPosition = new Position(line, column); + let c = column; + for(let l = line; l < range.end.line;) { + const currentLine = editor.lineAt(l); + for(;c < currentLine.range.end.character;c++) { + if(text[c] === '(') + count++; + + if(text[c] === ')') + count--; + } + + if(count === 0) { + return new Range(line - 1, column, l - 1, c); + } + + l++; + c = 0; + } + + return new Range(line, column, line, column); +} + export const cljParser = { R_CLJ_WHITE_SPACE, getExpressionInfo, getNamespace, + getDirectlyBeforeBlockRange }; diff --git a/src/clojureEval.ts b/src/clojureEval.ts index 75d5971..8e332e1 100644 --- a/src/clojureEval.ts +++ b/src/clojureEval.ts @@ -3,15 +3,35 @@ import * as vscode from 'vscode'; import { cljConnection } from './cljConnection'; import { cljParser } from './cljParser'; import { nreplClient } from './nreplClient'; +import {readBooleanConfiguration} from './utils'; -export function clojureEval(outputChannel: vscode.OutputChannel): void { +function getAlertOnEvalResult() { + return readBooleanConfiguration('aletOnEval'); +} + +export function clojureEval(outputChannel: vscode.OutputChannel): void { evaluate(outputChannel, false); } -export function clojureEvalAndShowResult(outputChannel: vscode.OutputChannel): void { +export function clojureEvalAndShowResult(outputChannel: vscode.OutputChannel): void { evaluate(outputChannel, true); } +export function evaluateText(outputChannel: vscode.OutputChannel, + showResults: boolean, + fileName: string, + text: string): Promise { + return cljConnection.sessionForFilename(fileName).then(session => { + return (fileName.length === 0 && session.type == 'ClojureScript') + // Piggieback's evalFile() ignores the text sent as part of the request + // and just loads the whole file content from disk. So we use eval() + // here, which as a drawback will give us a random temporary filename in + // the stacktrace should an exception occur. + ? nreplClient.evaluate(text, session.id) + : nreplClient.evaluateFile(text, fileName, session.id); + }); +} + function evaluate(outputChannel: vscode.OutputChannel, showResults: boolean): void { if (!cljConnection.isConnected()) { vscode.window.showWarningMessage('You should connect to nREPL first to evaluate code.'); @@ -26,28 +46,17 @@ function evaluate(outputChannel: vscode.OutputChannel, showResults: boolean): vo text = `(ns ${ns})\n${editor.document.getText(selection)}`; } - cljConnection.sessionForFilename(editor.document.fileName).then(session => { - let response; - if (!selection.isEmpty && session.type == 'ClojureScript') { - // Piggieback's evalFile() ignores the text sent as part of the request - // and just loads the whole file content from disk. So we use eval() - // here, which as a drawback will give us a random temporary filename in - // the stacktrace should an exception occur. - response = nreplClient.evaluate(text, session.id); - } else { - response = nreplClient.evaluateFile(text, editor.document.fileName, session.id); - } - response.then(respObjs => { - if (!!respObjs[0].ex) - return handleError(outputChannel, selection, showResults, respObjs[0].session); - - return handleSuccess(outputChannel, showResults, respObjs); - }) - }); + evaluateText(outputChannel, showResults, editor.document.fileName, text) + .then(respObjs => { + if (!!respObjs[0].ex) + return handleError(outputChannel, selection, showResults, respObjs[0].session); + + return handleSuccess(outputChannel, showResults, respObjs); + }); } -function handleError(outputChannel: vscode.OutputChannel, selection: vscode.Selection, showResults: boolean, session: string): Promise { - if (!showResults) +export function handleError(outputChannel: vscode.OutputChannel, selection: vscode.Selection, showResults: boolean, session: string): Promise { + if (!showResults && getAlertOnEvalResult()) vscode.window.showErrorMessage('Compilation error'); return nreplClient.stacktrace(session) @@ -75,8 +84,8 @@ function handleError(outputChannel: vscode.OutputChannel, selection: vscode.Sele }); } -function handleSuccess(outputChannel: vscode.OutputChannel, showResults: boolean, respObjs: any[]): void { - if (!showResults) { +export function handleSuccess(outputChannel: vscode.OutputChannel, showResults: boolean, respObjs: any[]): void { + if (!showResults && getAlertOnEvalResult()) { vscode.window.showInformationMessage('Successfully compiled'); } else { respObjs.forEach(respObj => { diff --git a/src/clojureLintingProvider.ts b/src/clojureLintingProvider.ts new file mode 100644 index 0000000..95583cc --- /dev/null +++ b/src/clojureLintingProvider.ts @@ -0,0 +1,234 @@ +import * as vscode from 'vscode'; +import * as nreplConnection from './cljConnection'; +import { nreplClient } from './nreplClient'; +import { resolve } from 'dns'; +import { Diagnostic, Range, TextDocument } from 'vscode'; +import { cljParser } from './cljParser'; +import { CLOJURE_MODE } from './clojureMode'; +import { handleError } from './clojureEval'; + +interface LinterWarningResult { + + msg: string; + line: number; + column: number; + linter: string; +} + +interface LinterErrorData { + column: number; + "end-column": number; + line: number; + "end-line": number; + file: string; +} + +interface LinterError { + cause: string; + trace: number; + data: LinterErrorData; +} + +interface LinterResult { + + err: string; + "err-data": LinterError; + warnings: LinterWarningResult[]; +} + +const errorsSeverity: string[] = ["bad-arglists", + "misplaced-docstrings", + "wrong-arity", + "wrong-ns-form", + "wrong-pre-post", + "wrong-tag"]; +const warningsSeverity: string[] = [":constant-test", + "def-in-def", + "deprecations", + "keyword-typos", + "local-shadows-var", + "redefd-vars", + "suspicious-expression", + "suspicious-test", + "unused-locals", + "unused-meta-on-macro", + "unused-namespaces", + "unused-private-vars"]; +const infoSeverity: string[] = ["no-ns-form-found", + "unlimited-use", + "unused-ret-vals", + "unused-ret-vals-in-try"]; + +export class ClojureLintingProvider { + + private outputChannel: vscode.OutputChannel; + private eastwoodInstalled: boolean = false; + + constructor(channel: vscode.OutputChannel) { + this.outputChannel = channel; + } + + private getLintCommand(ns: string): string { + return `(do (require '[eastwood.lint]) + (require '[clojure.data.json]) + (-> (eastwood.lint/lint {:namespaces ['${ns}]}) + (select-keys [:warnings :err :err-data]) + (update :warnings (fn [x] (map #(select-keys % [:msg :line :column :linter]) x))) + (update :err-data :exception) + ((fn [data] + (if-let [err-data (:err-data data)] + (-> data + (update :err-data Throwable->map) + (update :err-data #(select-keys % [:cause :data :trace])) + (update-in [:err-data :trace] + (fn [trace] + (->> trace + (filter #(clojure.string/starts-with? (.getClassName %) (str "${ns}" "$"))) + (first) + ((fn [x] (if x (.getLineNumber x))))))) + (or data)) + data))) + (clojure.data.json/write-str)))`; + } + + private diagnosticCollection: vscode.DiagnosticCollection; + + private parseLintSuccessResponse(response: string): LinterResult { + const parsedToString = JSON.parse(response); + return JSON.parse(parsedToString); + } + + private getSeverity(type: string) { + if (errorsSeverity.indexOf(type) > 0) { + return vscode.DiagnosticSeverity.Error; + } else if (warningsSeverity.indexOf(type) > 0) { + return vscode.DiagnosticSeverity.Warning; + } else if (infoSeverity.indexOf(type) > 0) { + return vscode.DiagnosticSeverity.Information; + } + else { + return vscode.DiagnosticSeverity.Hint; + } + } + + private createDiagnosticFromLintResult(document: TextDocument, warning: LinterWarningResult): Diagnostic { + const blockRange = cljParser.getDirectlyBeforeBlockRange(document, warning.line, warning.column); + const severity = this.getSeverity(warning.linter); + return new Diagnostic(blockRange, warning.msg, severity); + } + + private createDiagnosticCollectionFromLintResult(document: TextDocument, result: LinterResult): Diagnostic[] { + let warnings = result.warnings.map((item) => { return this.createDiagnosticFromLintResult(document, item); }); + if (result.err) { + const errData = result['err-data']; + if (errData.data != null && document.fileName.endsWith(errData.data.file)) { + const startLine = errData.data.line - 1; + const startChar = errData.data.column - 1; + const endLine = errData.data['end-line'] == null ? startLine : errData.data['end-line'] - 1; + const endChar = errData.data['end-column'] == null ? startChar : errData.data['end-column'] - 1; + warnings.push({ + range: new Range(errData.data.line - 1, errData.data.column - 1, endLine, endChar), + message: errData.cause, + source: "Linter Exception", + severity: vscode.DiagnosticSeverity.Error, + code: -1 + }); + } else { + const line = errData.trace != null ? 0 : 0; + warnings.push({ + range: new Range(line, 0, line, 0), + message: errData.cause, + source: "Linter Exception", + severity: vscode.DiagnosticSeverity.Error, + code: -1 + }); + } + } + + return warnings; + } + + private isEastwoodInstalled(textDocument: vscode.TextDocument) { + return nreplConnection.cljConnection + .sessionForFilename(textDocument.fileName) + .then(value => { + return nreplClient.evaluate("(require '[eastwood.lint])") + .then(result => { + try { + return result[0].ex == null; + } catch (e) { + return false; + } + }, () => false); + }, () => false); + } + + private lint(textDocument: vscode.TextDocument): void { + + if (!this.eastwoodInstalled) { + this.isEastwoodInstalled(textDocument) + .then(value => { + this.eastwoodInstalled = value || false; + if (this.eastwoodInstalled) + this.lint(textDocument); + }); + + return; + } + + if (textDocument.languageId !== CLOJURE_MODE.language + || !nreplConnection.cljConnection.isConnected + || !this.eastwoodInstalled) { + return; + } + + nreplConnection.cljConnection + .sessionForFilename(textDocument.fileName) + .then(value => { + const ns = cljParser.getNamespace(textDocument.getText()); + if (ns.length > 0) { + const command = this.getLintCommand(ns); + nreplClient.evaluate(command) + .then(result => { + try { + if (!!result[0].ex) { + handleError(this.outputChannel, + new vscode.Selection(0, 0, 0, 0), + false, + result[0].session); + } else { + let lintResult: LinterResult = this.parseLintSuccessResponse(result[0].value); + const diagnostics = this.createDiagnosticCollectionFromLintResult(textDocument, lintResult); + this.diagnosticCollection.set(textDocument.uri, diagnostics); + } + } catch (e) { + console.error(e); + } + + }, err => { + console.error(err); + }); + } + }); + } + + public activate(subscriptions: vscode.Disposable[]) { + subscriptions.push(this); + this.diagnosticCollection = vscode.languages.createDiagnosticCollection(); + + vscode.workspace.onDidOpenTextDocument(this.lint, this, subscriptions); + vscode.workspace.onDidCloseTextDocument((textDocument) => { + this.diagnosticCollection.delete(textDocument.uri); + }, null, subscriptions); + + vscode.workspace.onDidSaveTextDocument((textDocument: vscode.TextDocument) => { + this.diagnosticCollection.delete(textDocument.uri); + this.lint(textDocument); + }, this); + } + + public dispose(): void { + this.diagnosticCollection.clear(); + this.diagnosticCollection.dispose(); + } +} \ No newline at end of file diff --git a/src/clojureMain.ts b/src/clojureMain.ts index 9614418..4eb2c0d 100644 --- a/src/clojureMain.ts +++ b/src/clojureMain.ts @@ -11,16 +11,20 @@ import { JarContentProvider } from './jarContentProvider'; import { nreplController } from './nreplController'; import { cljConnection } from './cljConnection'; import { formatFile, maybeActivateFormatOnSave } from './clojureFormat'; +import { reloadNamespaceCommand, getReloadOnFileSave } from './clojureReloadNamespace'; +import { ClojureLintingProvider } from './clojureLintingProvider'; +import { ClojureReferenceProvider } from './clojureReferenceProvider'; export function activate(context: vscode.ExtensionContext) { + cljConnection.setCljContext(context); context.subscriptions.push(nreplController); cljConnection.disconnect(false); - var config = vscode.workspace.getConfiguration('clojureVSCode'); + var config = vscode.workspace.getConfiguration('clojureVSCode'); if (config.autoStartNRepl) { cljConnection.startNRepl(); } - + maybeActivateFormatOnSave(); vscode.commands.registerCommand('clojureVSCode.manuallyConnectToNRepl', cljConnection.manuallyConnect); @@ -30,8 +34,11 @@ export function activate(context: vscode.ExtensionContext) { const evaluationResultChannel = vscode.window.createOutputChannel('Evaluation results'); vscode.commands.registerCommand('clojureVSCode.eval', () => clojureEval(evaluationResultChannel)); vscode.commands.registerCommand('clojureVSCode.evalAndShowResult', () => clojureEvalAndShowResult(evaluationResultChannel)); - vscode.commands.registerTextEditorCommand('clojureVSCode.formatFile', formatFile); + vscode.commands.registerTextEditorCommand('clojureVSCode.reloadNamespace', ()=> { reloadNamespaceCommand(evaluationResultChannel); }); + + + //vscode.commands.registerTextEditorCommand('clojureVSCode.searchSymbol', searchSymbol); context.subscriptions.push(vscode.languages.registerCompletionItemProvider(CLOJURE_MODE, new ClojureCompletionItemProvider(), '.', '/')); context.subscriptions.push(vscode.languages.registerDefinitionProvider(CLOJURE_MODE, new ClojureDefinitionProvider())); @@ -40,6 +47,26 @@ export function activate(context: vscode.ExtensionContext) { vscode.workspace.registerTextDocumentContentProvider('jar', new JarContentProvider()); vscode.languages.setLanguageConfiguration(CLOJURE_MODE.language, new ClojureLanguageConfiguration()); + + if(getReloadOnFileSave()) { + vscode.workspace.onDidSaveTextDocument( + function (textDocument: vscode.TextDocument) { + reloadNamespaceCommand(evaluationResultChannel); + }, this); + } + + let linter = new ClojureLintingProvider(evaluationResultChannel); + linter.activate(context.subscriptions); + + context.subscriptions.push( + vscode.languages.registerReferenceProvider( + CLOJURE_MODE, new ClojureReferenceProvider() + ) + ); + + // context.subscriptions.push( + // vscode.languages.registerReferenceProvider() + // CLOJURE_MODE, new ClojureCompletionItemProvider(), '')); } export function deactivate() { } diff --git a/src/clojureReferenceProvider.ts b/src/clojureReferenceProvider.ts new file mode 100644 index 0000000..e28e8e0 --- /dev/null +++ b/src/clojureReferenceProvider.ts @@ -0,0 +1,93 @@ +import * as vscode from 'vscode'; +import { cljConnection } from './cljConnection'; +import { nreplClient } from './nreplClient'; +import { Uri, Location } from 'vscode'; +import {JarContentProvider} from './jarContentProvider'; +import { evaluateText } from './clojureEval'; +import { isNullOrUndefined } from 'util'; +import { cljParser } from './cljParser'; + + +class InfoResult { + public file: string; + public line: number; + public column: number; + + public fileToUri(): Uri { + return vscode.Uri.parse(this.file); + } +} + +export class ClojureReferenceProvider implements vscode.ReferenceProvider { + + private jarProvider: JarContentProvider = new JarContentProvider(); + + public provideReferences(document: vscode.TextDocument, + position: vscode.Position, + context: vscode.ReferenceContext, + token: vscode.CancellationToken) + : vscode.ProviderResult + { + if(!cljConnection.isConnected) + return []; + + const referenceRange = document.getWordRangeAtPosition(position); + const fileName = document.fileName; + + let symbolText = document.getText(referenceRange); + if(isNullOrUndefined(symbolText) || symbolText.length === 0) + return null; + + //TODO: instead of ignoring the namespace need to find the reference + const symbolParts = symbolText.split("/"); + let searchTerm; + if(symbolParts.length === 1) + searchTerm = `(clojure.repl/apropos #"^${symbolText}.{0,3}$")`; + else + searchTerm = `(clojure.repl/apropos #"^${symbolParts[1]}$")`; + + const command = `(clojure.repl/apropos #"^${searchTerm}.{0,3}$")`; + return cljConnection.sessionForFilename(fileName).then(session => { + return nreplClient.evaluateFile(command, fileName, session.id); + }).then((result)=>{ + try { + return result[0].value + .substring(1, result[0].value.length - 1) + .split(' '); + } catch(e) { + console.error(e); + return []; + } + }, console.error).then((symboles: Array) =>{ + const symbolesPromises = + symboles.map((item)=> { + if(isNullOrUndefined(item) || item.length === 0) + return Promise.resolve(null); + + const symbolesParts = item.split('/'); + return nreplClient.info(symbolesParts[1], symbolesParts[0]); + }); + + + return Promise.all(symbolesPromises) + .then((value: InfoResult[])=> { + + return value.filter(item=>item != null) + .map(item=>{ + try { + const fileUri: vscode.Uri = vscode.Uri.parse(item.file); + const range = new vscode.Range(item.line, item.column, item.line, item.column); + return new vscode.Location(fileUri, range); + } catch(e) { + console.error(e); + return null; + } + }); + + }, (e)=> { + console.error(e); + return []; + }); + }); + } +} \ No newline at end of file diff --git a/src/clojureReloadNamespace.ts b/src/clojureReloadNamespace.ts new file mode 100644 index 0000000..62198d9 --- /dev/null +++ b/src/clojureReloadNamespace.ts @@ -0,0 +1,38 @@ +import * as vscode from 'vscode'; +import { cljConnection } from './cljConnection'; +import { cljParser } from './cljParser'; +import { handleError, evaluateText } from './clojureEval'; +import { readBooleanConfiguration } from './utils'; + +export function getReloadOnFileSave(): boolean { + return readBooleanConfiguration('autoReloadNamespaceOnSave') +} + +export function reloadNamespaceCommand( + outputChannel: vscode.OutputChannel) { + + if (!cljConnection.isConnected()) { + return; + } + + const textDocument = vscode.window.activeTextEditor.document; + const fileName = textDocument.fileName; + if (!fileName.endsWith(".clj")) { + return; + } + + const text = textDocument.getText(); + const ns = cljParser.getNamespace(text); + const commantText = `(require '${ns} :reload)`; + + return evaluateText(outputChannel, false, fileName, commantText) + .then(respObjs => { + return (!!respObjs[0].ex) + ? handleError(outputChannel, + new vscode.Selection(0, 0, 0, 0), + false, + respObjs[0].session) + .then(value => Promise.reject(value)) + : Promise.resolve(); + }); +} \ No newline at end of file diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..4178b08 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,8 @@ +import * as vscode from 'vscode'; + +export function readBooleanConfiguration(configName) { + let editorConfig = vscode.workspace.getConfiguration('editor'); + const globalEditorConfig = editorConfig && editorConfig.has(configName) && editorConfig.get(configName) === true; + let clojureConfig = vscode.workspace.getConfiguration('clojureVSCode'); + return ((clojureConfig[configName] || globalEditorConfig)); +} \ No newline at end of file