diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..1ca120b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +# http://editorconfig.org + +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 4 +end_of_line = lf +trim_trailing_whitespace = true diff --git a/locale/lang.en-us.json b/locale/lang.en-us.json new file mode 100644 index 0000000..8a5030f --- /dev/null +++ b/locale/lang.en-us.json @@ -0,0 +1,14 @@ +{ + "info": { + "initializing": "Initializing REPL extension...", + "cwd": "[%s] working at:%s", + "starting": "[%s] starting to interpret %d bytes of code:", + "disposing": "Disposing REPL server..." + }, + "warn": { + "CRUD": " Warning: Be careful with CRUD operations since the code is running multiple times in REPL." + }, + "error": { + "notJavascript": "[Node.js REPL] Selected document is not Javascript, unable to start REPL here." + } +} \ No newline at end of file diff --git a/locale/lang.zh-cn.json b/locale/lang.zh-cn.json new file mode 100644 index 0000000..5590064 --- /dev/null +++ b/locale/lang.zh-cn.json @@ -0,0 +1,14 @@ +{ + "info": { + "initializing": "初始化 REPL 服务端中……", + "cwd": "[%s] 工作目录为:%s", + "starting": "[%s] 开始解释长度为 %d 字节的代码:", + "disposing": "销毁 REPL 服务端中……" + }, + "warn": { + "CRUD": " 警告: 注意对文件、数据库等等的增删查改操作,因为代码会在 REPL 中执行多次。" + }, + "error": { + "notJavascript": "[Node.js REPL] 选中的文档不是 Javascript 文件,不能在此执行 REPL。" + } +} \ No newline at end of file diff --git a/package.json b/package.json index 33af666..1586a4b 100644 --- a/package.json +++ b/package.json @@ -38,13 +38,13 @@ "contributes": { "commands": [{ "command": "extension.nodejsRepl", - "title": "Node.js Interactive window (REPL)" + "title": "%command.extension.nodejsRepl%" }, { "command": "extension.nodejsReplCurrent", - "title": "Node.js Interactive window (REPL) at selected file" + "title": "%command.extension.nodejsReplCurrent%" }, { "command": "extension.nodejsReplClose", - "title": "Stop Node.js Interactive window (REPL)" + "title": "%command.extension.nodejsReplClose%" }] }, "scripts": { diff --git a/package.nls.json b/package.nls.json new file mode 100644 index 0000000..fa9d9c9 --- /dev/null +++ b/package.nls.json @@ -0,0 +1,5 @@ +{ + "command.extension.nodejsRepl": "Node.js REPL: Start REPL at New Untitled File", + "command.extension.nodejsReplCurrent": "Node.js REPL: Start REPL at Current File", + "command.extension.nodejsReplClose": "Node.js REPL: Stop REPL" +} \ No newline at end of file diff --git a/package.nls.zh-cn.json b/package.nls.zh-cn.json new file mode 100644 index 0000000..180a1a1 --- /dev/null +++ b/package.nls.zh-cn.json @@ -0,0 +1,5 @@ +{ + "command.extension.nodejsRepl": "Node.js REPL: 在新建无标题文件运行交互式解释器", + "command.extension.nodejsReplCurrent": "Node.js REPL: 在当前编辑器运行交互式解释器", + "command.extension.nodejsReplClose": "Node.js REPL: 停止交互式解释器" +} \ No newline at end of file diff --git a/src/code.ts b/src/code.ts new file mode 100644 index 0000000..6f3b0cd --- /dev/null +++ b/src/code.ts @@ -0,0 +1,60 @@ +import * as Path from 'path'; +import * as Fs from 'fs'; + + +const importStatement = /import\s+(?:(\*\s+as\s)?([\w-_]+),?)?\s*(?:\{([^\}]+)\})?\s+from\s+["']([^"']+)["']/gi; + +export function rewriteImportToRequire(code: string): string { + return code.replace(importStatement, (str, wildcard: string, module: string, modules: string, from: string) => { + let rewrite = ''; + + if (module) + rewrite = `default: ${module}`; + + if (modules) + rewrite += (rewrite && ', ') + modules.replace(/\sas\s/g, ': '); + + return `const ${wildcard ? module : `{ ${rewrite} }`} = require('${from}');`; + }); +} + + +const requireStatement = /require\s*\(\s*(['"])([A-Z0-9_~\\\/\.]+)\s*\1\)/gi; + +export function rewriteModulePathInRequire(code: string, basePath: string, filePath: string): string { + return code.replace(requireStatement, (str, par, name) => { + let path = Path.join(basePath, 'node_modules', name); + + if (Fs.existsSync(path) === false) + path = (name.indexOf('/') === -1) + ? name + : (filePath) + ? Path.join(Path.dirname(filePath), name) + : Path.join(basePath, name); + + return `require('${path.replace(/\\/g, '\\\\')}')`; + }); +} + + +const lineBreak = /\r?\n/; +const consoleLogCall = /console\s*\.(debug|error|info|log|warn)\(/g; + +export function rewriteConsoleToAppendLineNumber(code: string): string { + let num = 0, + out = []; + + for (let line of code.split(lineBreak)) { + out.push(line.replace(consoleLogCall, (str, method) => `global['\`console\`'].${method}(${num}, `)); + num++; + } + + return out.join('\n'); +} + + +const lineBreakInChainCall = /([\n\s]+)\./gi; + +export function rewriteChainCallInOneLine(code: string): string { + return code.replace(lineBreakInChainCall, (str, whitespace) => `/*\`${whitespace.split(lineBreak).length - 1}\`*/.`); +} \ No newline at end of file diff --git a/src/decorator.ts b/src/decorator.ts new file mode 100644 index 0000000..fce9e35 --- /dev/null +++ b/src/decorator.ts @@ -0,0 +1,110 @@ +import { + DecorationOptions, + DecorationRangeBehavior, + MarkdownString, + OutputChannel, + Position, + Range, + TextEditor, + window, +} from "vscode"; + + +// create a decorator type that we use to decorate small numbers +const resultDecorationType = window.createTextEditorDecorationType({ + rangeBehavior: DecorationRangeBehavior.ClosedClosed, + light: {}, + dark: {}, +}); +const colorOfType = { + 'Value of Expression': 'green', + 'Console': '#457abb', + 'Error': 'red', +} +type Data = { line: number, value: string }; +type Result = { line: number, type: 'Value of Expression' | 'Console' | 'Error', text: string, value: string }; + +export default class Decorator { + private editor: TextEditor; + private lineToOutput: Map = new Map(); + private lineToDecorator: Map = new Map(); + private decorators: DecorationOptions[] = []; + + constructor(private outputChannel: OutputChannel) { } + + init(editor: TextEditor) { + this.editor = editor; + this.lineToOutput.clear(); + this.lineToDecorator.clear(); + this.decorators = []; + } + + async update(data: Data) { + if (!data) return; + + let result = this.format(data); + + this.outputChannel.appendLine(` ${result.value}`); + + if (result.type === 'Console') { + result = this.mergeConsoleOutput(result); + } + this.updateWith(result); + this.decorateAll(); + } + private format({ line, value }: Data): Result { + let match: RegExpExecArray; + + if ((match = /((\w*Error(?:\s\[[^\]]+\])?:\s.*)(?:\n\s*at\s[\s\S]+)?)$/.exec(value)) != null) { + return { line, type: 'Error', text: match[2], value: match[1] }; + } + else if ((match = /^`\{(\d+)\}`([\s\S]*)$/.exec(value)) != null) { + let value = match[2] || ''; + return { line: +match[1], type: 'Console', text: value.replace(/\r?\n/g, ' '), value }; + } + else { + return { line, type: 'Value of Expression', text: value.replace(/\r?\n/g, ' '), value }; + } + } + private mergeConsoleOutput({ line, text, value }: Result) { + let output = this.lineToOutput.get(line); + if (output == null) { + this.lineToOutput.set(line, output = { line, type: 'Console', text: '', value: '' }); + } + + output.text += (output.text && ', ') + text; + output.value += (output.value && '\n') + value; + + return output; + } + private updateWith({ line, type, text, value }: Result) { + let decorator: DecorationOptions; + + if ((decorator = this.lineToDecorator.get(line)) == null) { + let pos = new Position(line, Number.MAX_SAFE_INTEGER); + + decorator = { + renderOptions: { before: { margin: '0 0 0 1em' } }, + range: new Range(pos, pos) + }; + this.lineToDecorator.set(line, decorator); + this.decorators.push(decorator); + } + + decorator.renderOptions.before.color = colorOfType[type]; + decorator.renderOptions.before.contentText = ` ${text}`; + + decorator.hoverMessage = new MarkdownString(type) + .appendCodeblock(value, type === 'Error' ? 'text' : 'javascript'); + } + + decorateAll() { + this.decorate(this.decorators); + } + decorateExcept(line: number) { + this.decorate(this.decorators.filter(d => line != d.range.start.line)); + } + private decorate(decorations: DecorationOptions[]) { + this.editor.setDecorations(resultDecorationType, decorations); + } +} \ No newline at end of file diff --git a/src/extension.ts b/src/extension.ts index 09a6375..daf2c71 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -3,566 +3,88 @@ // Import the module and reference it with the alias vscode in your code below import { commands, - Disposable, + env, ExtensionContext, TextDocument, - Uri, - ViewColumn, + TextEditor, window, workspace, - WorkspaceEdit, - TextEditor, - Position, - Range, - DecorationOptions, - DecorationRangeBehavior, - MarkdownString + ViewColumn, + Disposable, } from 'vscode'; -import { EventEmitter } from 'events'; -import * as Repl from 'repl'; -import * as Path from 'path'; -import * as Fs from 'fs'; -import * as Util from 'util'; -import { Writable, Readable } from 'stream'; +import ReplClient from './repl-client'; +import i18n from './i18n'; -let replExt: ReplExtension; -let outputWindow = window.createOutputChannel("NodeJs REPL"); -// this method is called when your extension is activated -// your extension is activated the very first time the command is executed +const i18nTexts = i18n(env.language); +let outputWindow = window.createOutputChannel("NodeJs REPL"); +let registeredCommands: Disposable[]; +let client = new ReplClient(outputWindow); +let editor: TextEditor; +let doc: TextDocument; + +/** + * this method is called when your extension is activated + * your extension is activated the very first time the command is executed + */ export function activate(context: ExtensionContext) { - context.subscriptions.push(commands.registerCommand('extension.nodejsRepl', async () => { - try { - if(!replExt || replExt.disposed) - replExt = new ReplExtension(); - await replExt.close(); - await replExt.showEditor(); - - return; - } - catch(err) { - outputWindow.appendLine(err); - } - })); - - context.subscriptions.push(commands.registerCommand('extension.nodejsReplCurrent', async () => { - try { - if(!replExt || replExt.disposed) - replExt = new ReplExtension(); + registeredCommands = [ + commands.registerCommand('extension.nodejsRepl', async () => { + try { + doc = await workspace.openTextDocument({ content: '', language: 'javascript' }); + editor = await window.showTextDocument(doc, ViewColumn.Active); - await replExt.close(); - await replExt.openDocument(true); - await replExt.showEditor(); - await replExt.interpret(); - - return; - } - catch(err) { - outputWindow.appendLine(err); - } - })); - - context.subscriptions.push(commands.registerCommand('extension.nodejsReplClose', async () => { - try { - if(replExt && !replExt.disposed) { - await replExt.close(); - - replExt.dispose() + client.init(editor, doc); + await client.interpret(); } - - } - catch(err) { - outputWindow.appendLine(err); - } - })); - - - (async () => { - try { - for(let document of workspace.textDocuments) { - if(document.fileName.indexOf('Untitled-') >= 0 && document.languageId == 'javascript') { - if(!replExt || replExt.disposed) - replExt = new ReplExtension(); - - await replExt.showEditor(document); - await replExt.interpret(); - - break; - } - } - } - catch(err) { - outputWindow.appendLine(err); - } - })() - - -} - -// this method is called when your extension is deactivated -export function deactivate() { - replExt.dispose(); - outputWindow.dispose(); -} - -class ReplExtension { - private changeEventDisposable: Disposable; - private changeActiveDisposable: Disposable; - - private repl: NodeRepl; - - private editor: TextEditor; - private document: TextDocument; - - private interpretTimer: NodeJS.Timer = null; - - // create a decorator type that we use to decorate small numbers - private resultDecorationType = window.createTextEditorDecorationType({ - rangeBehavior: DecorationRangeBehavior.ClosedClosed, - light: { - - }, - dark: { - - } - }); - - private resultDecorators: Map = new Map(); - - constructor() { - this.init(); - } - - public get disposed() { - return this.repl == null; - } - - public dispose() { - if(outputWindow) - outputWindow.appendLine(`Disposing REPL extension`) - - this.changeActiveDisposable.dispose(); - this.changeEventDisposable.dispose(); - - this.repl = null; - } - - public async init() { - outputWindow.appendLine(`Initializing REPL extension with Node ${process.version}`) - outputWindow.appendLine(` Warning; Be careful with CRUD operations since the code is running multiple times in REPL.`) - - this.changeActiveDisposable = window.onDidChangeActiveTextEditor(async (editor) => { - if(this.editor && this.editor.document === editor.document) { - this.interpret(); + catch (err) { + outputWindow.appendLine(err); } - }); - - workspace.onDidCloseTextDocument(async (document) => { - if(this.editor && this.editor.document == document) - this.dispose(); - }); - - this.changeEventDisposable = workspace.onDidChangeTextDocument(async (event) => { - try - { - if (!this.editor || this.editor.document !== event.document ) { + }), + commands.registerCommand('extension.nodejsReplCurrent', async () => { + try { + if (window.activeTextEditor && window.activeTextEditor.document.languageId === 'javascript') { + doc = window.activeTextEditor.document; + editor = await window.showTextDocument(doc, ViewColumn.Active); + + } else { + window.showErrorMessage(i18nTexts.error.notJavascript); return; } - let change = event.contentChanges[0], - text = change.text; - - if(/\n/.test(text) == false && change.range.isSingleLine == true) - this.editor.setDecorations(this.resultDecorationType, Array.from(this.resultDecorators.values()).filter(d => { - return this.editor.selection.active.line != d.range.start.line; - })); - - if(this.interpretTimer) - clearTimeout(this.interpretTimer); - - if(text.indexOf(';') >= 0 || text.indexOf('\n') >= 0 || (text == '' && change.range.isSingleLine == false)) { - await this.interpret(); - } - else { - this.interpretTimer = setTimeout(async () => { - await this.interpret(); - }, 2000); - } - } - catch(err) { + client.init(editor, doc); + await client.interpret(); + } catch (err) { outputWindow.appendLine(err); } - }); - } - - public async interpret() { - try { - await this.showEditor(); - - let code = this.editor.document.getText(); - - this.resultDecorators.clear(); - - new NodeRepl() - .on('exit', () => { - if(this.resultDecorators.size == 0) - this.editor.setDecorations(this.resultDecorationType, []); - }) - .on('output', (result) => { - let decorator: DecorationOptions, - color: string; - - switch(result.type) { - case 'result': color = 'green'; break; - case 'error': color = 'red'; break; - case 'console': color = '#457abb'; break; - } - - if((decorator = this.resultDecorators.get(result.line)) == null) - { - let length = this.getTextAtLine(result.line - 1).length, - startPos = new Position(result.line - 1, length + 1 ), - endPos = new Position(result.line - 1, length + 1); - - this.resultDecorators.set(result.line, decorator = { renderOptions: { before: { margin: '0 0 0 1em', contentText: '', color: color } }, range: new Range(startPos, endPos) }); - } - - decorator.renderOptions.before.color = color; - decorator.renderOptions.before.contentText = ` ${result.text}`; - - decorator.hoverMessage = new MarkdownString(result.type.slice(0, 1).toUpperCase() + result.type.slice(1)); - decorator.hoverMessage.appendCodeblock( - result.type == 'console' ? result.value.join('\n') : result.value || result.text, - "javascript" - ); - - this.editor.setDecorations(this.resultDecorationType, Array.from(this.resultDecorators.values())); - }) - .interpret(code); - } - catch(ex) { - outputWindow.appendLine(ex); - - return false; - } - - return true; - } - - public async close(): Promise { - this.document = null; - } - - public async show(): Promise { - await this.showEditor(); - } - - public async openDocument(currentWindow: boolean = false) { - if(this.document == null || this.document.isClosed == true) { - if(currentWindow && window.activeTextEditor) { - if(window.activeTextEditor.document.languageId == 'javascript') { - return this.document = window.activeTextEditor.document; - } - else { - window.showErrorMessage('Selected document is not Javascript, unable to start REPL here'); - - return null; + }), + commands.registerCommand('extension.nodejsReplClose', async () => { + try { + if (!client.isClosed) { + client.dispose(); + editor = null; + doc = null; } + } catch (err) { + outputWindow.appendLine(err); } + }), + ]; - this.document = await workspace.openTextDocument({ content: '', language: 'javascript' }); - } - - return this.document; - } - - public async showEditor(document: TextDocument = undefined) { - if(document) - this.document = document; - - this.editor = await window.showTextDocument(this.document || await this.openDocument(), ViewColumn.Active, ); - - return this.editor; - } - - private getTextAtLine(line: number) { - let startPos = new Position(line, 0), - endPos = new Position(line, 37768); - - return this.editor.document.getText(new Range(startPos, endPos)); - } + context.subscriptions.push(...registeredCommands); } -class NodeRepl extends EventEmitter { - private replEval: (cmd: string, context: any, filename: string, cb: (err?: Error, result?: string) => void) => void; - private output: Map = new Map(); - - private basePath: string; - - constructor() { - super(); - - if(workspace && Array.isArray(workspace.workspaceFolders)) { - let doc = window.activeTextEditor.document; - this.basePath = (doc.isUntitled) - ? workspace.workspaceFolders[0].uri.fsPath - : workspace.getWorkspaceFolder(Uri.file(doc.fileName)).uri.fsPath; - - outputWindow.appendLine(`[${new Date().toLocaleTimeString()}] working at: ${this.basePath}`); - } - } - - public async interpret(code: string) { - try { - outputWindow.appendLine(`[${new Date().toLocaleTimeString()}] starting to interpret ${code.length} bytes of code`); - - let inputStream = new Readable({ - read: () => { } - }), - lineCount = 0, - outputCount = 0, - requireIdx = 0; - - this.output.clear(); // new interpretation, clear all outputs - - inputStream.push(""); - - let repl = Repl.start({ - prompt: '', - input: inputStream, - output: new Writable({ - write: (chunk, enc, cb) => { - let out = chunk.toString().trim(); - switch(out) { - case 'undefined': - case '...': - case '': - break; - - default: - let match: RegExpExecArray; - - if( (match = /(\w+:\s.*)\n\s*at\s/gi.exec(out)) != null) { - this.output.set(lineCount, {line: lineCount, type: 'error', text: match[1], value: match[1]}); - this.emit('output', {line: lineCount, type: 'error', text: match[1], value: match[1]}); - - outputWindow.appendLine(` ${match[1]}\n\tat line ${lineCount}`); - } - else if( (match = /`\{(\d+)\}`([\s\S]*)/gi.exec(out)) != null) { - let output = this.output.get( Number(match[1]) ); - if( output == null) - this.output.set(Number(match[1]), output = { line: Number(match[1]), type: 'console', text: '', value: [] }); - - output.text += (output.text == '' ? '' : ', ') + (match[2] || '').replace(/\r\n|\n/g, ' '); - output.value.push(match[2]); - - this.emit('output', output); - - outputWindow.appendLine(` ${match[2]}`); - } - else { - outputWindow.appendLine(` ${out}`); - } - - break; - } - cb(); - } - }), - writer: (out) => { - if(out == null) - return; - } - }) - - if(this.replEval == null) - this.replEval = (repl).eval; // keep a backup of original eval - - // nice place to read the result in sequence and inject it in the code - (repl).eval = (cmd: string, context: any, filename: string, cb: (err?: Error, result?: any) => void) => { - lineCount++; - - this.replEval(cmd, context, filename, (err, result: any) => { - let regex = /\/\*`(\d+)`\*\//gi, - match: RegExpExecArray - - if(!err) { - while((match = regex.exec(cmd)) != null) - lineCount += Number(match[1]); - - let currentLine = lineCount - - this.formatOutput(result) // we can't await this since callback of eval needs to be called synchronious - .then(output => { - if(output) { - this.output.set(currentLine, {line: currentLine, type: output.type, text: output.text, value: output.value}); - this.emit('output', { line: currentLine, type: output.type, text: `${output.text}`, value: output.value}); - - if(output.type == 'error') - outputWindow.appendLine(` ${err.name}: ${err.message}\n\tat line ${currentLine}`); - } - }) - } - - cb(err, result); - }) - } - - Object.defineProperty(repl.context, '_console', { - - value: function(line: number) { - let _log = function (text, ...args) { - repl.context.console.log(`\`{${line}}\`${typeof text === 'string' ? text : Util.inspect(text)}`, ...args); - } - - return Object.assign({}, repl.context.console, { - log: _log, - warn: _log, - error: _log - }) - } - - }); - - code = this.rewriteImport(code); - code = this.rewriteRequire(code); - code = this.rewriteConsole(code); - code = this.rewriteMethod(code); - - //inputStream.push(`require("${Path.join(this.basePath, "node_modules", "ts-node").replace(/\\/g, '\\\\')}").register({});\n`) - for(let line of code.split(/\r\n|\n/)) - { - inputStream.push(`${line}\n`); // tell the REPL about the line of code to see if there is any result coming out of it - } - - inputStream.push(`.exit\n`); - inputStream.push(null); - - repl.on('exit', () => { - setTimeout(() => this.emit('exit'), 100); - }) - } - catch(ex) - { - outputWindow.appendLine(ex); - } - } - - private async formatOutput(result: any): Promise<{type: 'result' | 'error', text: string, value: any }> { - switch(typeof(result)) - { - case 'undefined': - break; - - case 'object': - if(result.constructor && result.constructor.name == 'Promise' && result.then) { - try { - let ret = await Promise.resolve(result) - - return this.formatOutput(ret) - } - catch(ex) { - return { - type: 'error', - text: `${ex.name}: ${ex.message}`, - value: ex - } - } - } - - let text; - - if(Array.isArray(result)) { - text = JSON.stringify(result); - } - else { - text = JSON.stringify(result, null, "\t").replace(/\n/g, " "); - } - - return { - type: 'result', - text: text, - value: result - } - - default: - return { - type: 'result', - text: result.toString().replace(/(\r\n|\n)/g, ' '), - value: result - } - } - - } - - private rewriteImport(code: string): string { - let regex = /import\s*(?:(\*\s+as\s)?([\w-_]+),?)?\s*(?:\{([^\}]+)\})?\s+from\s+["']([^"']+)["']/gi, - match; - - return code.replace(regex, (str: string, wildcard: string, module: string, modules: string, from) => { - let rewrite = '', - path; - - if(module) - rewrite += `${rewrite == '' ? '' : ', '}default: ${module} `; - - if(modules) - rewrite += `${rewrite == '' ? '' : ', '}${modules - .split(',') - .map(r => r.replace(/\s*([\w-_]+)(?:\s+as\s+([\w-_]))?\s*/gi, (str, moduleName: string, moduleNewName: string) => { - return `${moduleNewName ? `${moduleNewName.trim()}: ` : ``}${moduleName.trim()}`; - })) - .join(', ')}`; - - return `const ${wildcard ? module : `{ ${rewrite} }`} = require('${from}')`; - }); - } - - private rewriteRequire(code: string): string { - let regex = /require\s*\(\s*(['"])([A-Z0-9_~\\\/\.]+)\s*\1\)/gi, - match: RegExpExecArray; - - return code.replace(regex, (str, par, name) => { - let doc = window.activeTextEditor.document; - let path = Path.join(this.basePath, 'node_modules', name); - - if(Fs.existsSync(path) === false) - path = (doc.isUntitled) - ? Path.normalize(Path.join(this.basePath, name)) - : Path.join(Path.dirname(doc.fileName), name); - - return `require('${path.replace(/\\/g, '\\\\')}')`; - }); - } - - private rewriteConsole(code: string): string { - let num = 0, - out = []; - - for(let line of code.split(/\r\n|\n/)) - { - out.push(line.replace(/console/g, `_console(${++num})`)); - } - - return out.join('\n'); - } - - private rewriteMethod(code: string): string { - let regex = /([\n\s]+)\./gi, - match; - - return code.replace(regex, (str: string, whitespace) => { - return `/*\`${whitespace.split(/\r\n|\n/).length - 1}\`*/.` - }); - } - - private isRecoverableError(error) { - if (error.name === 'SyntaxError') { - return /^(Unexpected end of input|Unexpected token)/.test(error.message); - } +/** + * this method is called when your extension is deactivated + */ +export function deactivate() { + outputWindow.dispose(); + client.dispose(); + editor = null; + doc = null; - return false; - } + for (let cmd of registeredCommands) + cmd.dispose(); } \ No newline at end of file diff --git a/src/i18n.ts b/src/i18n.ts new file mode 100644 index 0000000..70c8527 --- /dev/null +++ b/src/i18n.ts @@ -0,0 +1,38 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as util from 'util'; + +interface Resource { + info: { + initializing: string, + cwd: string, + starting: string, + disposing: string, + }, + warn: { + CRUD: string, + }, + error: { + notJavascript: string, + } +} + +let resource: Resource; + +export default function i18n(lang: string): Resource { + + if (resource) return resource; + + lang = lang.toLowerCase(); + let defaultLang = 'en-us'; + let langs = [lang, lang.split('-')[0], defaultLang]; + let localePath = path.join(__dirname, '../locale/lang.%s.json'); + + for (let language of langs) { + let langResourcePath = util.format(localePath, language); + if (fs.existsSync(langResourcePath)) { + return JSON.parse(fs.readFileSync(langResourcePath, 'utf8')); + } + } + throw new Error('Not found any language resource.'); +} \ No newline at end of file diff --git a/src/repl-client.ts b/src/repl-client.ts new file mode 100644 index 0000000..af48a71 --- /dev/null +++ b/src/repl-client.ts @@ -0,0 +1,159 @@ +import { + Disposable, + env, + OutputChannel, + TextEditor, + TextDocument, + window, + workspace, + Uri, +} from "vscode"; + +import { + rewriteImportToRequire, + rewriteModulePathInRequire, + rewriteConsoleToAppendLineNumber, + rewriteChainCallInOneLine +} from "./code"; + +import Decorator from "./decorator"; +import { spawn, ChildProcess } from "child_process"; +import { format } from 'util'; +import i18n from './i18n'; + +const i18nTexts = i18n(env.language); + +const serverArguments = [`${__dirname}/repl-server.js`]; + +const stdioOptions = ['ignore', 'ignore', 'ignore', 'ipc']; + +export default class ReplClient { + private changeEventDisposable: Disposable; + private closeTextDocumentDisposable: Disposable; + private changeActiveDisposable: Disposable; + + private editor: TextEditor; + private decorator: Decorator; + private basePath: string; + private filePath: string; + + private repl: ChildProcess; + + private editingTimer: NodeJS.Timer = null; + private afterEditTimer: NodeJS.Timer = null; + + constructor(private outputChannel: OutputChannel) { + this.decorator = new Decorator(outputChannel); + } + + private registerEventHandlers() { + this.changeActiveDisposable = window.onDidChangeActiveTextEditor(async (editor) => { + if (this.editor && editor && this.editor.document === editor.document) { + this.init(editor, editor.document); + this.interpret(); + } + }); + + this.closeTextDocumentDisposable = workspace.onDidCloseTextDocument(async (document) => { + if (this.editor && this.editor.document === document) + this.close(); + }); + + this.changeEventDisposable = workspace.onDidChangeTextDocument(async (event) => { + try { + if (!this.editor || this.editor.document !== event.document) + return; + + let change = event.contentChanges[0], + text = change.text; + + if (/\n/.test(text) === false && change.range.isSingleLine) { + let currentLine = this.editor.selection.active.line; + this.decorator.decorateExcept(currentLine); + } + + if (this.afterEditTimer) clearTimeout(this.afterEditTimer); + if (this.editingTimer) clearTimeout(this.editingTimer); + + if (text.lastIndexOf(';') >= 0 || text.lastIndexOf('\n') >= 0 || (text === '' && change.range.isSingleLine === false)) + this.editingTimer = setTimeout(async () => await this.interpret(), 600); + else + this.afterEditTimer = setTimeout(async () => await this.interpret(), 1500); + } + catch (err) { + this.outputChannel.appendLine(err); + } + }); + } + + init(editor: TextEditor, doc: TextDocument) { + this.outputChannel.appendLine(i18nTexts.info.initializing); + this.outputChannel.appendLine(i18nTexts.warn.CRUD); + + if (this.isDisposed) this.registerEventHandlers(); + + this.editor = editor; + + if (workspace && Array.isArray(workspace.workspaceFolders)) { + + this.basePath = (doc.isUntitled) + ? workspace.workspaceFolders[0].uri.fsPath + : workspace.getWorkspaceFolder(Uri.file(doc.fileName)).uri.fsPath; + + this.filePath = doc.isUntitled ? '' : doc.fileName; + + this.outputChannel.appendLine(format(i18nTexts.info.cwd, new Date().toLocaleTimeString(), this.basePath)); + } + } + + async interpret() { + try { + if (!this.isClosed) this.close(); + + this.decorator.init(this.editor); + + this.repl = spawn('node', serverArguments, { cwd: this.basePath, stdio: stdioOptions }) + .on('message', async result => await this.decorator.update(result)) + .on('error', err => this.outputChannel.appendLine(`[${new Date().toLocaleTimeString()}][REPL Server] ${err.message}`)); + + let code = this.editor.document.getText(); + + this.outputChannel.appendLine(format(i18nTexts.info.starting, new Date().toLocaleTimeString(), code.length)); + + // TODO: typescript REPL + // code = `require("${Path.join(this.basePath, "node_modules/ts-node").replace(/\\/g, '\\\\')}").register({});\n${code}`; + code = rewriteImportToRequire(code); + code = rewriteModulePathInRequire(code, this.basePath, this.filePath); + code = rewriteConsoleToAppendLineNumber(code); + code = rewriteChainCallInOneLine(code); + + this.repl.send({ code }); + } + catch (ex) { + this.outputChannel.appendLine(ex); + } + } + + close() { + if (this.outputChannel) + this.outputChannel.appendLine(i18nTexts.info.disposing); + + this.repl.send({ operation: 'exit' }); + this.repl = null; + } + get isClosed() { + return this.repl == null; + } + + dispose() { + if (!this.isClosed) this.close(); + + this.changeActiveDisposable.dispose(); + this.closeTextDocumentDisposable.dispose(); + this.changeEventDisposable.dispose(); + this.editor = null; + } + get isDisposed() { + return this.editor == null; + } +} \ No newline at end of file diff --git a/src/repl-server.ts b/src/repl-server.ts new file mode 100644 index 0000000..1174e03 --- /dev/null +++ b/src/repl-server.ts @@ -0,0 +1,60 @@ +import * as Repl from 'repl'; +import * as Util from 'util'; +import { Writable, Readable } from 'stream'; + + +type ReplServer = Repl.REPLServer & { inputStream: Readable }; + +let lineCount = 0; + +const server = Repl.start({ + prompt: '', + input: new Readable({ read: () => { } }), + output: new Writable({ + write: (chunk, encoding, callback) => { + let value = chunk.toString().trim(); + + if (value !== '' && /^\.{3,}$/.test(value) === false) + process.send({ line: lineCount, value }); + + callback(); + } + }), + ignoreUndefined: true, + +}) as ReplServer; + +const originLog = server.context.console.log; +const appendLineLog = (lineNumber: number, text: any, ...args: any[]) => { + originLog(`\`{${lineNumber}}\`${typeof text === 'string' ? text : Util.inspect(text)}`, ...args); +} +Object.defineProperty(server.context, '`console`', { + value: { + debug: appendLineLog, + error: appendLineLog, + info: appendLineLog, + log: appendLineLog, + warn: appendLineLog, + } +}); + +const lineNumber = /\/\*`(\d+)`\*\//g; + +process.on('message', data => { + + if (typeof data.code === 'string') { + for (let codeLine of data.code.split('\n')) { + let match: RegExpExecArray; + + while ((match = lineNumber.exec(codeLine)) != null) + lineCount += +match[1]; + + server.inputStream.push(codeLine + '\n'); + + lineCount++; + } + } + else if (data.operation === 'exit') { + process.exit(); + } +}); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 8a1d847..446f4e4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,8 @@ "es6" ], "sourceMap": true, - "rootDir": "src" + "rootDir": "src", + "removeComments": true }, "exclude": [ "node_modules",