diff --git a/browser/src/Editor/BufferManager.ts b/browser/src/Editor/BufferManager.ts index 92d2f64853..0e00aa57e2 100644 --- a/browser/src/Editor/BufferManager.ts +++ b/browser/src/Editor/BufferManager.ts @@ -88,6 +88,7 @@ export class Buffer implements IBuffer { private _filePath: string private _language: string private _cursor: Oni.Cursor + private _cursorOffset: number private _version: number private _modified: boolean private _lineCount: number @@ -111,6 +112,10 @@ export class Buffer implements IBuffer { return this._cursor } + public get cursorOffset(): number { + return this._cursorOffset + } + public get version(): number { return this._version } @@ -146,6 +151,18 @@ export class Buffer implements IBuffer { this._actions.removeBufferLayer(parseInt(this._id, 10), layer) } + /** + * convertOffsetToLineColumn + */ + public async convertOffsetToLineColumn( + cursorOffset = this._cursorOffset, + ): Promise { + const line: number = await this._neovimInstance.callFunction("byte2line", [cursorOffset]) + const countFromLine: number = await this._neovimInstance.callFunction("line2byte", [line]) + const column = cursorOffset - countFromLine + return types.Position.create(line - 1, column) + } + public async getCursorPosition(): Promise { const pos = await this._neovimInstance.callFunction("getpos", ["."]) const [, oneBasedLine, oneBasedColumn] = pos @@ -213,10 +230,10 @@ export class Buffer implements IBuffer { const bufferLinesPromise = this.getLines(0, 1024) const detectIndentPromise = import("detect-indent") - await Promise.all([bufferLinesPromise, detectIndentPromise]) - - const bufferLines = await bufferLinesPromise - const detectIndent = await detectIndentPromise + const [bufferLines, detectIndent] = await Promise.all([ + bufferLinesPromise, + detectIndentPromise, + ]) const ret = detectIndent(bufferLines.join("\n")) @@ -414,6 +431,7 @@ export class Buffer implements IBuffer { this._version = evt.version this._modified = evt.modified this._lineCount = evt.bufferTotalLines + this._cursorOffset = evt.byte this._cursor = { line: evt.line - 1, diff --git a/browser/src/Services/Configuration/DefaultConfiguration.ts b/browser/src/Services/Configuration/DefaultConfiguration.ts index 2512bf1bc2..42f745c7dc 100644 --- a/browser/src/Services/Configuration/DefaultConfiguration.ts +++ b/browser/src/Services/Configuration/DefaultConfiguration.ts @@ -262,6 +262,22 @@ const BaseConfiguration: IConfigurationValues = { "oni.status.git": 3, }, + "oni.plugins.prettier": { + settings: { + semi: false, + tabWidth: 2, + useTabs: false, + singleQuote: false, + trailingComma: "es5", + bracketSpacing: true, + jsxBracketSameLine: false, + arrowParens: "avoid", + printWidth: 80, + }, + formatOnSave: false, + enabled: false, + }, + "tabs.mode": "tabs", "tabs.height": "2.5em", "tabs.highlight": true, diff --git a/browser/src/Services/Configuration/IConfigurationValues.ts b/browser/src/Services/Configuration/IConfigurationValues.ts index 98dcbcab83..a9057af45d 100644 --- a/browser/src/Services/Configuration/IConfigurationValues.ts +++ b/browser/src/Services/Configuration/IConfigurationValues.ts @@ -227,6 +227,24 @@ export interface IConfigurationValues { "sidebar.marks.enabled": boolean "sidebar.plugins.enabled": boolean + "oni.plugins.prettier": { + settings: { + semi: boolean + tabWidth: number + useTabs: boolean + singleQuote: boolean + trailingComma: "es5" | "all" | "none" + bracketSpacing: boolean + jsxBracketSameLine: boolean + arrowParens: "avoid" | "always" + printWidth: number + [key: string]: number | string | boolean + } + formatOnSave: boolean + enabled: boolean + allowedFiletypes?: string[] + } + "snippets.enabled": boolean "snippets.userSnippetFolder": string diff --git a/extensions/oni-plugin-prettier/.eslintrc.json b/extensions/oni-plugin-prettier/.eslintrc.json new file mode 100644 index 0000000000..ccd62afc2c --- /dev/null +++ b/extensions/oni-plugin-prettier/.eslintrc.json @@ -0,0 +1,45 @@ +{ + "rules": { + "indent": 0, + "linebreak-style": ["off", "unix"], + "quotes": ["error", "double", { "allowTemplateLiterals": true }], + "semi": ["error", "never"], + "array-callback-return": "error", + "eqeqeq": "error", + "no-console": "on", + "strict": 0, + "no-unused-vars": 0, + "no-undef": 1, + "no-undefined": 1, + "camelcase": 1, + "no-underscore-dangle": 0, + "no-console": 0, + "no-invalid-this": 1, + "no-useless-return": 1, + "comma-dangle": ["error", "only-multiline"], + "comma-spacing": ["error", { "before": false, "after": true }] + }, + "env": { + "es6": true, + "browser": true, + "jasmine": true, + "mocha": true + }, + "settings": { + "ecmascript": 6, + "jsx": true + }, + "extends": "eslint:recommended", + "parser": "babel-eslint", + "plugins": ["react"], + "globals": { + "_": false, + "remote": false, + "process": false, + "module": false, + "require": false, + "__dirname": false, + "preloadedData": false + }, + "root": true +} diff --git a/extensions/oni-plugin-prettier/index.js b/extensions/oni-plugin-prettier/index.js new file mode 100644 index 0000000000..7836cbdf7e --- /dev/null +++ b/extensions/oni-plugin-prettier/index.js @@ -0,0 +1,201 @@ +const path = require("path") +const prettier = require("prettier") + +// Helper functions +const compose = (...fns) => argument => fns.reduceRight((arg, fn) => fn(arg), argument) +const joinOrSplit = (method, by = "\n") => array => array[method](by) +const join = joinOrSplit("join") +const split = joinOrSplit("split") +const isEqual = toCompare => initialItem => initialItem === toCompare +const isTrue = (...args) => args.every(a => Boolean(a)) +const eitherOr = (...args) => args.find(a => !!a) +const flatten = multidimensional => [].concat(...multidimensional) + +const isCompatible = (allowedFiletypes, defaultFiletypes) => filePath => { + const filetypes = isTrue(allowedFiletypes, Array.isArray(allowedFiletypes)) + ? allowedFiletypes + : defaultFiletypes + const extension = path.extname(filePath) + return filetypes.includes(extension) +} + +const getSupportedLanguages = async () => { + const info = await prettier.getSupportInfo() + return flatten(info.languages.map(lang => lang.extensions)) +} + +const activate = async Oni => { + const config = Oni.configuration.getValue("oni.plugins.prettier") + const prettierItem = Oni.statusBar.createItem(0, "oni.plugins.prettier") + + const applyPrettierWithState = applyPrettier() + const defaultFiletypes = await getSupportedLanguages() + + const callback = async () => { + const isNormalMode = Oni.editors.activeEditor.mode === "normal" + if (isNormalMode) { + await applyPrettierWithState(Oni) + } + } + Oni.commands.registerCommand({ + command: "autoformat.prettier", + name: "Autoformat with Prettier", + execute: callback, + }) + + const checkPrettierrc = async bufferPath => { + if (!bufferPath) { + throw new Error(`No buffer path passed for prettier to check for a Prettierrc`) + } + try { + return await prettier.resolveConfig(bufferPath) + } catch (e) { + throw new Error(`Error parsing config file, ${e}`) + } + } + + // Status Bar Component ---- + const { errorElement, successElement, prettierElement } = createPrettierComponent(Oni, callback) + + prettierItem.setContents(prettierElement) + + const setStatusBarContents = (statusBarItem, defaultElement) => async ( + statusElement, + timeOut = 3500, + ) => { + statusBarItem.setContents(statusElement) + await setTimeout(() => statusBarItem.setContents(defaultElement), timeOut) + } + + const setPrettierStatus = setStatusBarContents(prettierItem, prettierElement) + + function applyPrettier() { + // Track the buffer state within the function using a closure + // if the buffer as a string is the same as the last state + // do no format because nothing has changed + let lastBufferState = null + + // pass in Oni explicitly - Make dependencies clearer + return async Oni => { + const { activeBuffer } = Oni.editors.activeEditor + + const [arrayOfLines, { line, character }] = await Promise.all([ + activeBuffer.getLines(), + activeBuffer.getCursorPosition(), + ]) + + const hasNotChanged = compose(isEqual(lastBufferState), join) + + if (hasNotChanged(arrayOfLines)) { + return + } + + try { + const prettierrc = await checkPrettierrc(activeBuffer.filePath) + const prettierConfig = eitherOr(prettierrc, config.settings) + + // Pass in the file path so prettier can infer the correct parser to use + const { formatted, cursorOffset } = prettier.formatWithCursor( + join(arrayOfLines), + Object.assign({ filepath: activeBuffer.filePath }, prettierConfig, { + cursorOffset: activeBuffer.cursorOffset, + }), + ) + if (!formatted) { + throw new Error("Couldn't format the buffer") + } + + await setPrettierStatus(successElement) + + const withoutFinalCR = formatted.replace(/\n$/, "") + lastBufferState = withoutFinalCR + + const [, { character, line }] = await Promise.all([ + activeBuffer.setLines(0, arrayOfLines.length, split(withoutFinalCR)), + activeBuffer.convertOffsetToLineColumn(cursorOffset), + ]) + + await activeBuffer.setCursorPosition(line, character) + await Oni.editors.activeEditor.neovim.command("w") + } catch (e) { + console.warn(`Couldn't format the buffer because: ${e}`) + await setPrettierStatus(errorElement) + } + } + } + + const { allowedFiletypes, formatOnSave, enabled } = config + const checkCompatibility = isCompatible(allowedFiletypes, defaultFiletypes) + + Oni.editors.activeEditor.onBufferEnter.subscribe(({ filePath }) => { + const hasCompatibility = checkCompatibility(filePath) + + hasCompatibility ? prettierItem.show() : prettierItem.hide() + }) + + Oni.editors.activeEditor.onBufferSaved.subscribe(async ({ filePath }) => { + const hasCompatibility = checkCompatibility(filePath) + + const canApplyPrettier = isTrue(formatOnSave, enabled, hasCompatibility) + if (canApplyPrettier) { + await applyPrettierWithState(Oni) + } + }) + return { applyPrettier: applyPrettierWithState, checkCompatibility, checkPrettierrc } +} + +function createPrettierComponent(Oni, onClick) { + const { React } = Oni.dependencies + + const background = Oni.colors.getColor("highlight.mode.normal.background") + const foreground = Oni.colors.getColor("highlight.mode.normal.foreground") + const style = { + width: "100%", + height: "100%", + display: "flex", + alignItems: "center", + justifyContent: "center", + paddingLeft: "8px", + paddingRight: "8px", + color: "white", + backgroundColor: foreground, + } + + const prettierIcon = (type = "magic") => + Oni.ui.createIcon({ + name: type, + size: Oni.ui.iconSize.Default, + }) + + const iconContainer = (type, color = "white") => + React.createElement("div", { style: { padding: "0 6px 0 0", color } }, prettierIcon(type)) + + const prettierElement = React.createElement( + "div", + { className: "prettier", style, onClick }, + iconContainer(), + "prettier", + ) + + const errorElement = React.createElement( + "div", + { style, className: "prettier" }, + iconContainer("exclamation-triangle", "yellow"), + "prettier", + ) + + const successElement = React.createElement( + "div", + { style, className: "prettier" }, + iconContainer("check", "#5AB379"), + "prettier", + ) + + return { + errorElement, + prettierElement, + successElement, + } +} + +module.exports = { activate } diff --git a/extensions/oni-plugin-prettier/package.json b/extensions/oni-plugin-prettier/package.json new file mode 100644 index 0000000000..a4dfce72e7 --- /dev/null +++ b/extensions/oni-plugin-prettier/package.json @@ -0,0 +1,18 @@ +{ + "name": "oni-plugin-prettier", + "version": "1.0.0", + "main": "index.js", + "engines": { + "oni": "^0.2.6" + }, + "scripts": {}, + "oni": { + "supportedFileTypes": [ + "*" + ] + }, + "dependencies": { + "prettier": "^1.11.1" + }, + "devDependencies": {} +} diff --git a/package.json b/package.json index 2c6ba250f6..fb7e402460 100644 --- a/package.json +++ b/package.json @@ -838,8 +838,9 @@ "watch:plugins:oni-plugin-markdown-preview": "cd extensions/oni-plugin-markdown-preview && tsc --watch", "uninstall-global": "npm rm -g oni-vim", "install-global": "npm install -g oni-vim", - "install:plugins": "npm run install:plugins:oni-plugin-markdown-preview", + "install:plugins": "npm run install:plugins:oni-plugin-markdown-preview && npm run install:plugins:oni-plugin-prettier", "install:plugins:oni-plugin-markdown-preview": "cd extensions/oni-plugin-markdown-preview && npm install --prod", + "install:plugins:oni-plugin-prettier": "cd extensions/oni-plugin-prettier && npm install --prod", "postinstall": "npm run install:plugins && electron-rebuild && opencollective postinstall", "profile:webpack": "webpack --config browser/webpack.production.config.js --profile --json > stats.json && webpack-bundle-analyzer browser/stats.json" }, diff --git a/test/CiTests.ts b/test/CiTests.ts index 31b3f0efff..43540a05c1 100644 --- a/test/CiTests.ts +++ b/test/CiTests.ts @@ -22,12 +22,14 @@ const CiTests = [ "Configuration.TypeScriptEditor.NewConfigurationTest", "Configuration.TypeScriptEditor.CompletionTest", + "Editor.BuffersCursorTest", "Editor.ExternalCommandLineTest", "Editor.BufferModifiedState", "Editor.OpenFile.PathWithSpacesTest", "Editor.TabModifiedState", "Editor.CloseTabWithTabModesTabsTest", "MarkdownPreviewTest", + "PrettierPluginTest", "PaintPerformanceTest", "QuickOpenTest", "StatusBar-Mode", diff --git a/test/ci/Common.ts b/test/ci/Common.ts index d70f00c49b..1c6edb2637 100644 --- a/test/ci/Common.ts +++ b/test/ci/Common.ts @@ -74,3 +74,17 @@ export const waitForCommand = async (command: string, oni: Oni.Plugin.Api): Prom return anyCommands.hasCommand(command) }, 10000) } + +export async function awaitEditorMode(oni: Oni.Plugin.Api, mode: string): Promise { + function condition(): boolean { + return oni.editors.activeEditor.mode === mode + } + await oni.automation.waitFor(condition) +} + +export async function insertText(oni: Oni.Plugin.Api, text: string): Promise { + oni.automation.sendKeys("i") + await awaitEditorMode(oni, "insert") + oni.automation.sendKeys(`${text}`) + await awaitEditorMode(oni, "normal") +} diff --git a/test/ci/Editor.BuffersCursorTest.ts b/test/ci/Editor.BuffersCursorTest.ts new file mode 100644 index 0000000000..70bff114cb --- /dev/null +++ b/test/ci/Editor.BuffersCursorTest.ts @@ -0,0 +1,37 @@ +/* + * Test Cursor functionality of oni buffers + * + */ +import * as assert from "assert" +import * as Oni from "oni-api" + +import { createNewFile, getTemporaryFilePath, insertText, navigateToFile } from "./Common" + +export const test = async (oni: Oni.Plugin.Api) => { + await oni.automation.waitForEditors() + + createNewFile("ts", oni) + await insertText(oni, "console.log('apple')") + + const activeBuffer: any = oni.editors.activeEditor.activeBuffer + + const { cursorOffset } = activeBuffer + + assert.equal(cursorOffset, 20) + + // Test that a cursor offset is correctly converted to a line and character + // results are 0 based + const { line, character } = await activeBuffer.convertOffsetToLineColumn(cursorOffset) + + assert.equal(line, 0) + assert.equal(character, 19) + // Check that the cursor position from the conversion matches the getCursorPosition method + // results + const { + line: currentLine, + character: currentCharacter, + } = await activeBuffer.getCursorPosition() + + assert.strictEqual(line, currentLine) + assert.strictEqual(currentCharacter, character) +} diff --git a/test/ci/MarkdownPreviewTest.tsx b/test/ci/MarkdownPreviewTest.tsx index bd5a5330c2..fcaf6e83e1 100644 --- a/test/ci/MarkdownPreviewTest.tsx +++ b/test/ci/MarkdownPreviewTest.tsx @@ -3,7 +3,7 @@ */ import { Assertor } from "./Assert" -import { getTemporaryFilePath, navigateToFile } from "./Common" +import { awaitEditorMode, getTemporaryFilePath, insertText, navigateToFile } from "./Common" import * as Oni from "oni-api" @@ -46,17 +46,3 @@ export async function test(oni: Oni.Plugin.Api) { "Preview pane with rendered header element", ) } - -async function awaitEditorMode(oni: Oni.Plugin.Api, mode: string): Promise { - function condition(): boolean { - return oni.editors.activeEditor.mode === mode - } - await oni.automation.waitFor(condition) -} - -async function insertText(oni: Oni.Plugin.Api, text: string): Promise { - oni.automation.sendKeys("i") - await awaitEditorMode(oni, "insert") - oni.automation.sendKeys(`${text}`) - await awaitEditorMode(oni, "normal") -} diff --git a/test/ci/PrettierPluginTest.ts b/test/ci/PrettierPluginTest.ts new file mode 100644 index 0000000000..573a6b1e56 --- /dev/null +++ b/test/ci/PrettierPluginTest.ts @@ -0,0 +1,88 @@ +/** + * Test the Prettier plugin + */ + +import * as stock_assert from "assert" +import * as os from "os" +import { Assertor } from "./Assert" +import { + awaitEditorMode, + createNewFile, + getElementByClassName, + getTemporaryFilePath, + insertText, +} from "./Common" + +import * as Oni from "oni-api" + +interface IPluginManager { + getPlugin(name: string): any +} + +interface IPrettierPlugin { + checkCompatibility(filePath: string): boolean + applyPrettier(): void + checkPrettierrc(): boolean +} + +export const settings = { + config: { + "oni.useDefaultConfig": true, + "oni.loadInitVim": false, + "oni.plugins.prettier": { + settings: { + semi: false, + tabWidth: 2, + useTabs: false, + singleQuote: false, + trailingComma: "es5", + bracketSpacing: true, + jsxBracketSameLine: false, + arrowParens: "avoid", + printWidth: 80, + }, + formatOnSave: true, + enabled: true, + allowedFiletypes: [".js", ".jsx", ".ts", ".tsx", ".md", ".html", ".json", ".graphql"], + }, + }, +} + +export async function test(oni: Oni.Plugin.Api) { + const assert = new Assertor("Prettier-plugin") + + await oni.automation.waitForEditors() + await createNewFile("ts", oni) + + await insertText(oni, "function test(){console.log('test')};") + + await oni.automation.waitFor(() => oni.plugins.loaded) + + // Test that the prettier status bar item is present + const prettierElement = getElementByClassName("prettier") + assert.defined(prettierElement, "Prettier status icon element is present") + + const prettierPlugin: IPrettierPlugin = await oni.plugins.getPlugin("oni-plugin-prettier") + assert.defined(prettierPlugin, "plugin instance") + assert.defined(prettierPlugin.applyPrettier, "plugin formatting method") + + const { activeBuffer } = oni.editors.activeEditor + assert.assert( + prettierPlugin.checkCompatibility(activeBuffer.filePath), + "If valid filetype prettier plugin check should return true", + ) + + // Test that in a Typescript file the plugin formats the buffer on save + oni.automation.sendKeys("0") + oni.automation.sendKeys(":") + oni.automation.sendKeys("w") + oni.automation.sendKeys("") + + await oni.automation.sleep(5000) + + const bufferText = await activeBuffer.getLines() + const bufferString = bufferText.join(os.EOL) + assert.assert(!bufferString.includes(";"), "Semi colons are removed from the text") + assert.assert(!bufferString.includes("'"), "Single quotes are removed from the formatted text") + assert.assert(bufferText.length === 3, "The code is split into 3 lines") +} diff --git a/vim/core/oni-core-interop/plugin/init.vim b/vim/core/oni-core-interop/plugin/init.vim index 2eb1b66ac4..1631a77040 100644 --- a/vim/core/oni-core-interop/plugin/init.vim +++ b/vim/core/oni-core-interop/plugin/init.vim @@ -167,7 +167,7 @@ let context.windowTopLine = line("w0") let context.windowBottomLine = line("w$") let context.windowWidth = winwidth(winnr()) let context.windowHeight = winheight(winnr()) -let context.byte = line2byte(line(".")) + col(".") +let context.byte = line2byte (line ( "." ) ) + col ( "." ) - 1 let context.filetype = eval("&filetype") let context.modified = &modified let context.hidden = &hidden