From 293eb2020bfe781e96235167c74b20ed6dc41295 Mon Sep 17 00:00:00 2001 From: Gert Date: Fri, 1 Jun 2018 21:27:00 +0200 Subject: [PATCH] Decaffeinate to switch to ES6 JavaScript from CoffeeScript This is just the initial conversion along with a few bugfixes to get everything (seemingly) working. None of the suggestions or TODO's have been implemented yet. Fixes #375 --- .../AbstractAnnotationProvider.coffee | 337 ---- lib/Annotations/AbstractAnnotationProvider.js | 438 +++++ .../MethodAnnotationProvider.coffee | 115 -- lib/Annotations/MethodAnnotationProvider.js | 143 ++ .../PropertyAnnotationProvider.coffee | 82 - lib/Annotations/PropertyAnnotationProvider.js | 107 ++ lib/AtomConfig.coffee | 97 - lib/AtomConfig.js | 124 ++ lib/AutocompletionProvider.coffee | 300 --- lib/AutocompletionProvider.js | 381 ++++ lib/ComposerService.coffee | 154 -- lib/ComposerService.js | 197 ++ lib/Config.coffee | 113 -- lib/Config.js | 143 ++ lib/ConfigTester.coffee | 26 - lib/ConfigTester.js | 39 + lib/CoreManager.coffee | 111 -- lib/CoreManager.js | 139 ++ lib/DatatipProvider.coffee | 84 - lib/DatatipProvider.js | 108 ++ lib/GotoDefinitionProvider.coffee | 64 - lib/GotoDefinitionProvider.js | 87 + lib/IndexingMediator.coffee | 147 -- lib/IndexingMediator.js | 178 ++ lib/LinterProvider.coffee | 218 --- lib/LinterProvider.js | 262 +++ lib/Main.coffee | 1448 -------------- lib/Main.js | 1684 +++++++++++++++++ lib/PhpInvoker.coffee | 169 -- lib/PhpInvoker.js | 208 ++ lib/ProjectManager.coffee | 415 ---- lib/ProjectManager.js | 492 +++++ lib/Proxy.coffee | 1141 ----------- lib/Proxy.js | 1349 +++++++++++++ lib/Refactoring/AbstractProvider.coffee | 117 -- lib/Refactoring/AbstractProvider.js | 170 ++ .../ConstructorGenerationProvider.coffee | 234 --- .../ConstructorGenerationProvider.js | 279 +++ .../ConstructorGenerationProvider/View.coffee | 35 - .../ConstructorGenerationProvider/View.js | 50 + lib/Refactoring/DocblockProvider.coffee | 395 ---- lib/Refactoring/DocblockProvider.js | 471 +++++ lib/Refactoring/ExtractMethodProvider.coffee | 330 ---- lib/Refactoring/ExtractMethodProvider.js | 391 ++++ .../ExtractMethodProvider/Builder.coffee | 374 ---- .../ExtractMethodProvider/Builder.js | 433 +++++ .../ParameterParser.coffee | 325 ---- .../ExtractMethodProvider/ParameterParser.js | 393 ++++ .../ExtractMethodProvider/View.coffee | 275 --- lib/Refactoring/ExtractMethodProvider/View.js | 360 ++++ lib/Refactoring/GetterSetterProvider.coffee | 304 --- lib/Refactoring/GetterSetterProvider.js | 360 ++++ .../GetterSetterProvider/View.coffee | 35 - lib/Refactoring/GetterSetterProvider/View.js | 50 + .../IntroducePropertyProvider.coffee | 127 -- lib/Refactoring/IntroducePropertyProvider.js | 165 ++ lib/Refactoring/OverrideMethodProvider.coffee | 209 -- lib/Refactoring/OverrideMethodProvider.js | 248 +++ .../OverrideMethodProvider/View.coffee | 35 - .../OverrideMethodProvider/View.js | 50 + .../StubAbstractMethodProvider.coffee | 169 -- lib/Refactoring/StubAbstractMethodProvider.js | 206 ++ .../StubAbstractMethodProvider/View.coffee | 35 - .../StubAbstractMethodProvider/View.js | 50 + .../StubInterfaceMethodProvider.coffee | 168 -- .../StubInterfaceMethodProvider.js | 205 ++ .../StubInterfaceMethodProvider/View.coffee | 35 - .../StubInterfaceMethodProvider/View.js | 50 + .../Utility/DocblockBuilder.coffee | 110 -- lib/Refactoring/Utility/DocblockBuilder.js | 148 ++ .../Utility/FunctionBuilder.coffee | 362 ---- lib/Refactoring/Utility/FunctionBuilder.js | 426 +++++ .../Utility/MultiSelectionView.coffee | 234 --- lib/Refactoring/Utility/MultiSelectionView.js | 286 +++ lib/Refactoring/Utility/TypeHelper.coffee | 195 -- lib/Refactoring/Utility/TypeHelper.js | 230 +++ lib/Service.coffee | 605 ------ lib/Service.js | 687 +++++++ lib/SignatureHelpProvider.coffee | 71 - lib/SignatureHelpProvider.js | 92 + ...{SymbolHelpers.coffee => SymbolHelpers.js} | 33 +- lib/UseStatementHelper.coffee | 237 --- lib/UseStatementHelper.js | 301 +++ lib/Widgets/AttachedPopover.coffee | 68 - lib/Widgets/AttachedPopover.js | 95 + lib/Widgets/Popover.coffee | 65 - lib/Widgets/Popover.js | 85 + 87 files changed, 12378 insertions(+), 10185 deletions(-) delete mode 100644 lib/Annotations/AbstractAnnotationProvider.coffee create mode 100644 lib/Annotations/AbstractAnnotationProvider.js delete mode 100644 lib/Annotations/MethodAnnotationProvider.coffee create mode 100644 lib/Annotations/MethodAnnotationProvider.js delete mode 100644 lib/Annotations/PropertyAnnotationProvider.coffee create mode 100644 lib/Annotations/PropertyAnnotationProvider.js delete mode 100644 lib/AtomConfig.coffee create mode 100644 lib/AtomConfig.js delete mode 100644 lib/AutocompletionProvider.coffee create mode 100644 lib/AutocompletionProvider.js delete mode 100644 lib/ComposerService.coffee create mode 100644 lib/ComposerService.js delete mode 100644 lib/Config.coffee create mode 100644 lib/Config.js delete mode 100644 lib/ConfigTester.coffee create mode 100644 lib/ConfigTester.js delete mode 100644 lib/CoreManager.coffee create mode 100644 lib/CoreManager.js delete mode 100644 lib/DatatipProvider.coffee create mode 100644 lib/DatatipProvider.js delete mode 100644 lib/GotoDefinitionProvider.coffee create mode 100644 lib/GotoDefinitionProvider.js delete mode 100644 lib/IndexingMediator.coffee create mode 100644 lib/IndexingMediator.js delete mode 100644 lib/LinterProvider.coffee create mode 100644 lib/LinterProvider.js delete mode 100644 lib/Main.coffee create mode 100644 lib/Main.js delete mode 100644 lib/PhpInvoker.coffee create mode 100644 lib/PhpInvoker.js delete mode 100644 lib/ProjectManager.coffee create mode 100644 lib/ProjectManager.js delete mode 100644 lib/Proxy.coffee create mode 100644 lib/Proxy.js delete mode 100644 lib/Refactoring/AbstractProvider.coffee create mode 100644 lib/Refactoring/AbstractProvider.js delete mode 100644 lib/Refactoring/ConstructorGenerationProvider.coffee create mode 100644 lib/Refactoring/ConstructorGenerationProvider.js delete mode 100644 lib/Refactoring/ConstructorGenerationProvider/View.coffee create mode 100644 lib/Refactoring/ConstructorGenerationProvider/View.js delete mode 100644 lib/Refactoring/DocblockProvider.coffee create mode 100644 lib/Refactoring/DocblockProvider.js delete mode 100644 lib/Refactoring/ExtractMethodProvider.coffee create mode 100644 lib/Refactoring/ExtractMethodProvider.js delete mode 100644 lib/Refactoring/ExtractMethodProvider/Builder.coffee create mode 100644 lib/Refactoring/ExtractMethodProvider/Builder.js delete mode 100644 lib/Refactoring/ExtractMethodProvider/ParameterParser.coffee create mode 100644 lib/Refactoring/ExtractMethodProvider/ParameterParser.js delete mode 100644 lib/Refactoring/ExtractMethodProvider/View.coffee create mode 100644 lib/Refactoring/ExtractMethodProvider/View.js delete mode 100644 lib/Refactoring/GetterSetterProvider.coffee create mode 100644 lib/Refactoring/GetterSetterProvider.js delete mode 100644 lib/Refactoring/GetterSetterProvider/View.coffee create mode 100644 lib/Refactoring/GetterSetterProvider/View.js delete mode 100644 lib/Refactoring/IntroducePropertyProvider.coffee create mode 100644 lib/Refactoring/IntroducePropertyProvider.js delete mode 100644 lib/Refactoring/OverrideMethodProvider.coffee create mode 100644 lib/Refactoring/OverrideMethodProvider.js delete mode 100644 lib/Refactoring/OverrideMethodProvider/View.coffee create mode 100644 lib/Refactoring/OverrideMethodProvider/View.js delete mode 100644 lib/Refactoring/StubAbstractMethodProvider.coffee create mode 100644 lib/Refactoring/StubAbstractMethodProvider.js delete mode 100644 lib/Refactoring/StubAbstractMethodProvider/View.coffee create mode 100644 lib/Refactoring/StubAbstractMethodProvider/View.js delete mode 100644 lib/Refactoring/StubInterfaceMethodProvider.coffee create mode 100644 lib/Refactoring/StubInterfaceMethodProvider.js delete mode 100644 lib/Refactoring/StubInterfaceMethodProvider/View.coffee create mode 100644 lib/Refactoring/StubInterfaceMethodProvider/View.js delete mode 100644 lib/Refactoring/Utility/DocblockBuilder.coffee create mode 100644 lib/Refactoring/Utility/DocblockBuilder.js delete mode 100644 lib/Refactoring/Utility/FunctionBuilder.coffee create mode 100644 lib/Refactoring/Utility/FunctionBuilder.js delete mode 100644 lib/Refactoring/Utility/MultiSelectionView.coffee create mode 100644 lib/Refactoring/Utility/MultiSelectionView.js delete mode 100644 lib/Refactoring/Utility/TypeHelper.coffee create mode 100644 lib/Refactoring/Utility/TypeHelper.js delete mode 100644 lib/Service.coffee create mode 100644 lib/Service.js delete mode 100644 lib/SignatureHelpProvider.coffee create mode 100644 lib/SignatureHelpProvider.js rename lib/{SymbolHelpers.coffee => SymbolHelpers.js} (50%) delete mode 100644 lib/UseStatementHelper.coffee create mode 100644 lib/UseStatementHelper.js delete mode 100644 lib/Widgets/AttachedPopover.coffee create mode 100644 lib/Widgets/AttachedPopover.js delete mode 100644 lib/Widgets/Popover.coffee create mode 100644 lib/Widgets/Popover.js diff --git a/lib/Annotations/AbstractAnnotationProvider.coffee b/lib/Annotations/AbstractAnnotationProvider.coffee deleted file mode 100644 index db9fcaa5..00000000 --- a/lib/Annotations/AbstractAnnotationProvider.coffee +++ /dev/null @@ -1,337 +0,0 @@ -module.exports = - -##* -# Base class for annotation providers. -## -class AbstractProvider - ###* - * List of markers that are present for each file. - * - * @var {Object} - ### - markers: null - - ###* - * A mapping of file names to a list of annotations that are inside the gutter. - * - * @var {Object} - ### - annotations: null - - ###* - * The service (that can be used to query the source code and contains utility methods). - ### - service: null - - constructor: () -> - # Constructer here because otherwise the object is shared between instances. - @markers = {} - @annotations = {} - - ###* - * Initializes this provider. - * - * @param {mixed} service - ### - activate: (@service) -> - dependentPackage = 'language-php' - - # It could be that the dependent package is already active, in that case we can continue immediately. If not, - # we'll need to wait for the listener to be invoked - if atom.packages.isPackageActive(dependentPackage) - @doActualInitialization() - - atom.packages.onDidActivatePackage (packageData) => - return if packageData.name != dependentPackage - - @doActualInitialization() - - atom.packages.onDidDeactivatePackage (packageData) => - return if packageData.name != dependentPackage - - @deactivate() - - ###* - * Does the actual initialization. - ### - doActualInitialization: () -> - atom.workspace.observeTextEditors (editor) => - if /text.html.php$/.test(editor.getGrammar().scopeName) - # Allow the active project to settle before registering for the first time. - setTimeout(() => - @registerAnnotations(editor) - @registerEvents(editor) - , 100) - - # When you go back to only have one pane the events are lost, so need to re-register. - atom.workspace.onDidDestroyPane (pane) => - panes = atom.workspace.getPanes() - - if panes.length == 1 - @registerEventsForPane(panes[0]) - - # Having to re-register events as when a new pane is created the old panes lose the events. - atom.workspace.onDidAddPane (observedPane) => - panes = atom.workspace.getPanes() - - for pane in panes - if pane != observedPane - @registerEventsForPane(pane) - - # Ensure annotations are updated. - @service.onDidFinishIndexing (data) => - editor = @findTextEditorByPath(data.path) - - if editor? - @rescan(editor) - - ###* - * Retrieves the text editor that is managing the file with the specified path. - * - * @param {String} path - * - * @return {TextEditor|null} - ### - findTextEditorByPath: (path) -> - for textEditor in atom.workspace.getTextEditors() - if textEditor.getPath() == path - return textEditor - - return null - - ###* - * Registers the necessary event handlers for the editors in the specified pane. - * - * @param {Pane} pane - ### - registerEventsForPane: (pane) -> - for paneItem in pane.items - if atom.workspace.isTextEditor(paneItem) - if /text.html.php$/.test(paneItem.getGrammar().scopeName) - @registerEvents(paneItem) - - ###* - * Deactives the provider. - ### - deactivate: () -> - @removeAnnotations() - - ###* - * Registers the necessary event handlers. - * - * @param {TextEditor} editor TextEditor to register events to. - ### - registerEvents: (editor) -> - # Ticket #107 - Mouseout isn't generated until the mouse moves, even when scrolling (with the keyboard or - # mouse). If the element goes out of the view in the meantime, its HTML element disappears, never removing - # it. - editor.onDidDestroy () => - @removePopover() - - editor.onDidStopChanging () => - @removePopover() - - textEditorElement = atom.views.getView(editor) - - textEditorElement.querySelector('.horizontal-scrollbar')?.addEventListener 'scroll', (event) => - @removePopover() - - textEditorElement.querySelector('.vertical-scrollbar')?.addEventListener 'scroll', (event) => - @removePopover() - - gutterContainerElement = textEditorElement.querySelector('.gutter-container') - - mouseOverHandler = (event) => - annotation = @getRelevantAnnotationForEvent(editor, event) - - return if not annotation? - - @handleMouseOver(event, editor, annotation.annotationInfo) - - mouseOutHandler = (event) => - annotation = @getRelevantAnnotationForEvent(editor, event) - - return if not annotation? - - @handleMouseOut(event, editor, annotation.annotationInfo) - - mouseDownHandler = (event) => - annotation = @getRelevantAnnotationForEvent(editor, event) - - return if not annotation? - - # Don't collapse or expand the fold in the gutter, if there is any. - event.stopPropagation() - - @handleMouseClick(event, editor, annotation.annotationInfo) - - gutterContainerElement?.addEventListener('mouseover', mouseOverHandler) - gutterContainerElement?.addEventListener('mouseout', mouseOutHandler) - gutterContainerElement?.addEventListener('mousedown', mouseDownHandler) - - - ###* - * @param {TextEditor} editor - * @param {Object} event - * - * @return {Object|null} - ### - getRelevantAnnotationForEvent: (editor, event) -> - if event.target.className.indexOf('icon-right') != -1 - longTitle = editor.getLongTitle() - - lineEventOccurredOn = parseInt(event.target.parentElement.dataset.bufferRow) - - if longTitle of @annotations - for annotation in @annotations[longTitle] - if annotation.line == lineEventOccurredOn - return annotation - - return null - - ###* - * Registers the annotations. - * - * @param {TextEditor} editor The editor to search through. - * - * @return {Promise|null} - ### - registerAnnotations: (editor) -> - throw new Error("This method is abstract and must be implemented!") - - ###* - * Places an annotation at the specified line and row text. - * - * @param {TextEditor} editor - * @param {Range} range - * @param {Object} annotationInfo - ### - placeAnnotation: (editor, range, annotationInfo) -> - marker = editor.markBufferRange(range, { - invalidate : 'touch' - }) - - decoration = editor.decorateMarker(marker, { - type: 'line-number', - class: annotationInfo.lineNumberClass - }) - - longTitle = editor.getLongTitle() - - if longTitle not of @markers - @markers[longTitle] = [] - - @markers[longTitle].push(marker) - - @registerAnnotationEventHandlers(editor, range.start.row, annotationInfo) - - ###* - * Registers annotation event handlers for the specified row. - * - * @param {TextEditor} editor - * @param {Number} row - * @param {Object} annotationInfo - ### - registerAnnotationEventHandlers: (editor, row, annotationInfo) -> - textEditorElement = atom.views.getView(editor) - gutterContainerElement = textEditorElement.querySelector('.gutter-container') - - do (editor, gutterContainerElement, annotationInfo) => - longTitle = editor.getLongTitle() - - if longTitle not of @annotations - @annotations[longTitle] = [] - - @annotations[longTitle].push({ - line : row - annotationInfo : annotationInfo - }) - - ###* - * Handles the mouse over event on an annotation. - * - * @param {Object} event - * @param {TextEditor} editor - * @param {Object} annotationInfo - ### - handleMouseOver: (event, editor, annotationInfo) -> - if annotationInfo.tooltipText - @removePopover() - - @attachedPopover = @service.createAttachedPopover(event.target) - @attachedPopover.setText(annotationInfo.tooltipText) - @attachedPopover.show() - - ###* - * Handles the mouse out event on an annotation. - * - * @param {Object} event - * @param {TextEditor} editor - * @param {Object} annotationInfo - ### - handleMouseOut: (event, editor, annotationInfo) -> - @removePopover() - - ###* - * Handles the mouse click event on an annotation. - * - * @param {Object} event - * @param {TextEditor} editor - * @param {Object} annotationInfo - ### - handleMouseClick: (event, editor, annotationInfo) -> - - ###* - * Removes the existing popover, if any. - ### - removePopover: () -> - if @attachedPopover - @attachedPopover.dispose() - @attachedPopover = null - - ###* - * Removes any annotations that were created with the specified key. - * - * @param {String} key - ### - removeAnnotationsByKey: (key) -> - for i,marker of @markers[key] - marker.destroy() - - @markers[key] = [] - @annotations[key] = [] - - ###* - * Removes any annotations (across all editors). - ### - removeAnnotations: () -> - for key,markers of @markers - @removeAnnotationsByKey(key) - - @markers = {} - @annotations = {} - - ###* - * Rescans the editor, updating all annotations. - * - * @param {TextEditor} editor The editor to search through. - ### - rescan: (editor) -> - key = editor.getLongTitle() - renamedKey = 'tmp_' + key - - # We rename the markers and remove them afterwards to prevent flicker if the location of the marker does not - # change. - if key of @annotations - @annotations[renamedKey] = @annotations[key] - @annotations[key] = [] - - if key of @markers - @markers[renamedKey] = @markers[key] - @markers[key] = [] - - result = @registerAnnotations(editor) - - if result? - result.then () => - @removeAnnotationsByKey(renamedKey) diff --git a/lib/Annotations/AbstractAnnotationProvider.js b/lib/Annotations/AbstractAnnotationProvider.js new file mode 100644 index 00000000..17aee189 --- /dev/null +++ b/lib/Annotations/AbstractAnnotationProvider.js @@ -0,0 +1,438 @@ +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS103: Rewrite code to no longer use __guard__ + * DS205: Consider reworking code to avoid use of IIFEs + * DS206: Consider reworking classes to avoid initClass + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let AbstractProvider; +module.exports = + +//#* +// Base class for annotation providers. +//# +(AbstractProvider = (function() { + AbstractProvider = class AbstractProvider { + static initClass() { + /** + * List of markers that are present for each file. + * + * @var {Object} + */ + this.prototype.markers = null; + + /** + * A mapping of file names to a list of annotations that are inside the gutter. + * + * @var {Object} + */ + this.prototype.annotations = null; + + /** + * The service (that can be used to query the source code and contains utility methods). + */ + this.prototype.service = null; + } + + constructor() { + // Constructer here because otherwise the object is shared between instances. + this.markers = {}; + this.annotations = {}; + } + + /** + * Initializes this provider. + * + * @param {mixed} service + */ + activate(service) { + this.service = service; + const dependentPackage = 'language-php'; + + // It could be that the dependent package is already active, in that case we can continue immediately. If not, + // we'll need to wait for the listener to be invoked + if (atom.packages.isPackageActive(dependentPackage)) { + this.doActualInitialization(); + } + + atom.packages.onDidActivatePackage(packageData => { + if (packageData.name !== dependentPackage) { return; } + + return this.doActualInitialization(); + }); + + return atom.packages.onDidDeactivatePackage(packageData => { + if (packageData.name !== dependentPackage) { return; } + + return this.deactivate(); + }); + } + + /** + * Does the actual initialization. + */ + doActualInitialization() { + atom.workspace.observeTextEditors(editor => { + if (/text.html.php$/.test(editor.getGrammar().scopeName)) { + // Allow the active project to settle before registering for the first time. + return setTimeout(() => { + this.registerAnnotations(editor); + return this.registerEvents(editor); + } + , 100); + } + }); + + // When you go back to only have one pane the events are lost, so need to re-register. + atom.workspace.onDidDestroyPane(pane => { + const panes = atom.workspace.getPanes(); + + if (panes.length === 1) { + return this.registerEventsForPane(panes[0]); + } + }); + + // Having to re-register events as when a new pane is created the old panes lose the events. + atom.workspace.onDidAddPane(observedPane => { + const panes = atom.workspace.getPanes(); + + return (() => { + const result = []; + for (let pane of Array.from(panes)) { + if (pane !== observedPane) { + result.push(this.registerEventsForPane(pane)); + } else { + result.push(undefined); + } + } + return result; + })(); + }); + + // Ensure annotations are updated. + return this.service.onDidFinishIndexing(data => { + const editor = this.findTextEditorByPath(data.path); + + if (editor != null) { + return this.rescan(editor); + } + }); + } + + /** + * Retrieves the text editor that is managing the file with the specified path. + * + * @param {String} path + * + * @return {TextEditor|null} + */ + findTextEditorByPath(path) { + for (let textEditor of Array.from(atom.workspace.getTextEditors())) { + if (textEditor.getPath() === path) { + return textEditor; + } + } + + return null; + } + + /** + * Registers the necessary event handlers for the editors in the specified pane. + * + * @param {Pane} pane + */ + registerEventsForPane(pane) { + return (() => { + const result = []; + for (let paneItem of Array.from(pane.items)) { + if (atom.workspace.isTextEditor(paneItem)) { + if (/text.html.php$/.test(paneItem.getGrammar().scopeName)) { + result.push(this.registerEvents(paneItem)); + } else { + result.push(undefined); + } + } else { + result.push(undefined); + } + } + return result; + })(); + } + + /** + * Deactives the provider. + */ + deactivate() { + return this.removeAnnotations(); + } + + /** + * Registers the necessary event handlers. + * + * @param {TextEditor} editor TextEditor to register events to. + */ + registerEvents(editor) { + // Ticket #107 - Mouseout isn't generated until the mouse moves, even when scrolling (with the keyboard or + // mouse). If the element goes out of the view in the meantime, its HTML element disappears, never removing + // it. + editor.onDidDestroy(() => { + return this.removePopover(); + }); + + editor.onDidStopChanging(() => { + return this.removePopover(); + }); + + const textEditorElement = atom.views.getView(editor); + + __guard__(textEditorElement.querySelector('.horizontal-scrollbar'), x => x.addEventListener('scroll', event => { + return this.removePopover(); + })); + + __guard__(textEditorElement.querySelector('.vertical-scrollbar'), x1 => x1.addEventListener('scroll', event => { + return this.removePopover(); + })); + + const gutterContainerElement = textEditorElement.querySelector('.gutter-container'); + + const mouseOverHandler = event => { + const annotation = this.getRelevantAnnotationForEvent(editor, event); + + if ((annotation == null)) { return; } + + return this.handleMouseOver(event, editor, annotation.annotationInfo); + }; + + const mouseOutHandler = event => { + const annotation = this.getRelevantAnnotationForEvent(editor, event); + + if ((annotation == null)) { return; } + + return this.handleMouseOut(event, editor, annotation.annotationInfo); + }; + + const mouseDownHandler = event => { + const annotation = this.getRelevantAnnotationForEvent(editor, event); + + if ((annotation == null)) { return; } + + // Don't collapse or expand the fold in the gutter, if there is any. + event.stopPropagation(); + + return this.handleMouseClick(event, editor, annotation.annotationInfo); + }; + + if (gutterContainerElement != null) { + gutterContainerElement.addEventListener('mouseover', mouseOverHandler); + } + if (gutterContainerElement != null) { + gutterContainerElement.addEventListener('mouseout', mouseOutHandler); + } + return (gutterContainerElement != null ? gutterContainerElement.addEventListener('mousedown', mouseDownHandler) : undefined); + } + + + /** + * @param {TextEditor} editor + * @param {Object} event + * + * @return {Object|null} + */ + getRelevantAnnotationForEvent(editor, event) { + if (event.target.className.indexOf('icon-right') !== -1) { + const longTitle = editor.getLongTitle(); + + const lineEventOccurredOn = parseInt(event.target.parentElement.dataset.bufferRow); + + if (longTitle in this.annotations) { + for (let annotation of Array.from(this.annotations[longTitle])) { + if (annotation.line === lineEventOccurredOn) { + return annotation; + } + } + } + } + + return null; + } + + /** + * Registers the annotations. + * + * @param {TextEditor} editor The editor to search through. + * + * @return {Promise|null} + */ + registerAnnotations(editor) { + throw new Error("This method is abstract and must be implemented!"); + } + + /** + * Places an annotation at the specified line and row text. + * + * @param {TextEditor} editor + * @param {Range} range + * @param {Object} annotationInfo + */ + placeAnnotation(editor, range, annotationInfo) { + const marker = editor.markBufferRange(range, { + invalidate : 'touch' + }); + + const decoration = editor.decorateMarker(marker, { + type: 'line-number', + class: annotationInfo.lineNumberClass + }); + + const longTitle = editor.getLongTitle(); + + if (!(longTitle in this.markers)) { + this.markers[longTitle] = []; + } + + this.markers[longTitle].push(marker); + + return this.registerAnnotationEventHandlers(editor, range.start.row, annotationInfo); + } + + /** + * Registers annotation event handlers for the specified row. + * + * @param {TextEditor} editor + * @param {Number} row + * @param {Object} annotationInfo + */ + registerAnnotationEventHandlers(editor, row, annotationInfo) { + const textEditorElement = atom.views.getView(editor); + const gutterContainerElement = textEditorElement.querySelector('.gutter-container'); + + return ((editor, gutterContainerElement, annotationInfo) => { + const longTitle = editor.getLongTitle(); + + if (!(longTitle in this.annotations)) { + this.annotations[longTitle] = []; + } + + return this.annotations[longTitle].push({ + line : row, + annotationInfo + }); + })(editor, gutterContainerElement, annotationInfo); + } + + /** + * Handles the mouse over event on an annotation. + * + * @param {Object} event + * @param {TextEditor} editor + * @param {Object} annotationInfo + */ + handleMouseOver(event, editor, annotationInfo) { + if (annotationInfo.tooltipText) { + this.removePopover(); + + this.attachedPopover = this.service.createAttachedPopover(event.target); + this.attachedPopover.setText(annotationInfo.tooltipText); + return this.attachedPopover.show(); + } + } + + /** + * Handles the mouse out event on an annotation. + * + * @param {Object} event + * @param {TextEditor} editor + * @param {Object} annotationInfo + */ + handleMouseOut(event, editor, annotationInfo) { + return this.removePopover(); + } + + /** + * Handles the mouse click event on an annotation. + * + * @param {Object} event + * @param {TextEditor} editor + * @param {Object} annotationInfo + */ + handleMouseClick(event, editor, annotationInfo) {} + + /** + * Removes the existing popover, if any. + */ + removePopover() { + if (this.attachedPopover) { + this.attachedPopover.dispose(); + return this.attachedPopover = null; + } + } + + /** + * Removes any annotations that were created with the specified key. + * + * @param {String} key + */ + removeAnnotationsByKey(key) { + for (let i in this.markers[key]) { + const marker = this.markers[key][i]; + marker.destroy(); + } + + this.markers[key] = []; + return this.annotations[key] = []; + } + + /** + * Removes any annotations (across all editors). + */ + removeAnnotations() { + let markers; + for (let key in this.markers) { + markers = this.markers[key]; + this.removeAnnotationsByKey(key); + } + + this.markers = {}; + return this.annotations = {}; + } + + /** + * Rescans the editor, updating all annotations. + * + * @param {TextEditor} editor The editor to search through. + */ + rescan(editor) { + const key = editor.getLongTitle(); + const renamedKey = `tmp_${key}`; + + // We rename the markers and remove them afterwards to prevent flicker if the location of the marker does not + // change. + if (key in this.annotations) { + this.annotations[renamedKey] = this.annotations[key]; + this.annotations[key] = []; + } + + if (key in this.markers) { + this.markers[renamedKey] = this.markers[key]; + this.markers[key] = []; + } + + const result = this.registerAnnotations(editor); + + if (result != null) { + return result.then(() => { + return this.removeAnnotationsByKey(renamedKey); + }); + } + } + }; + AbstractProvider.initClass(); + return AbstractProvider; +})()); + +function __guard__(value, transform) { + return (typeof value !== 'undefined' && value !== null) ? transform(value) : undefined; +} \ No newline at end of file diff --git a/lib/Annotations/MethodAnnotationProvider.coffee b/lib/Annotations/MethodAnnotationProvider.coffee deleted file mode 100644 index 5fe38eb8..00000000 --- a/lib/Annotations/MethodAnnotationProvider.coffee +++ /dev/null @@ -1,115 +0,0 @@ -shell = require 'shell' - -{Range} = require 'atom' - -AbstractAnnotationProvider = require './AbstractAnnotationProvider' - -module.exports = - -##* -# Provides annotations for member methods that are overrides or interface implementations. -## -class MethodProvider extends AbstractAnnotationProvider - ###* - * @inheritdoc - ### - registerAnnotations: (editor) -> - path = editor.getPath() - - return null if not path - - successHandler = (classInfo) => - return null if not classInfo - - for name, method of classInfo.methods - continue if not method.override and method.implementations?.length == 0 - continue if method.declaringStructure.fqcn != classInfo.fqcn - - range = new Range([method.startLine - 1, 0], [method.startLine, -1]) - - @placeAnnotation(editor, range, @extractAnnotationInfo(method)) - - failureHandler = () => - # Just do nothing. - - getClassListHandler = (classesInEditor) => - promises = [] - - for fqcn, classInfo of classesInEditor - promises.push @service.getClassInfo(fqcn).then(successHandler, failureHandler) - - return Promise.all(promises) - - return @service.getClassListForFile(path).then(getClassListHandler, failureHandler) - - ###* - * Fetches annotation info for the specified context. - * - * @param {Object} context - * - * @return {Object} - ### - extractAnnotationInfo: (context) -> - extraData = null - tooltipText = '' - lineNumberClass = '' - - if context.override - # NOTE: We deliberately show the declaring class here, not the structure (which could be a trait). However, - # if the method is overriding a trait method from the *same* class, we show the trait name, as it would be - # strange to put an annotation in "Foo" saying "Overrides method from Foo". - overriddenFromFqcn = context.override.declaringClass.fqcn - - if overriddenFromFqcn == context.declaringClass.fqcn - overriddenFromFqcn = context.override.declaringStructure.fqcn - - extraData = context.override - - if not context.override.wasAbstract - lineNumberClass = 'override' - tooltipText = 'Overrides method from ' + overriddenFromFqcn - - else - lineNumberClass = 'abstract-override' - tooltipText = 'Implements abstract method from ' + overriddenFromFqcn - - else - # NOTE: We deliberately show the declaring class here, not the structure (which could be a trait). - extraData = context.implementations[0] - lineNumberClass = 'implementations' - tooltipText = 'Implements method for ' + extraData.declaringStructure.fqcn - - extraData.fqcn = context.fqcn - - return { - lineNumberClass : lineNumberClass - tooltipText : tooltipText - extraData : extraData - } - - ###* - * @inheritdoc - ### - handleMouseClick: (event, editor, annotationInfo) -> - # 'filename' can be false for overrides of members from PHP's built-in classes (e.g. Exception). - if annotationInfo.extraData.declaringStructure.filename - atom.workspace.open(annotationInfo.extraData.declaringStructure.filename, { - initialLine : annotationInfo.extraData.declaringStructure.startLineMember - 1, - searchAllPanes : true - }) - - else - url = @service.getDocumentationUrlForClassMethod( - annotationInfo.extraData.declaringStructure.fqcn, - annotationInfo.extraData.fqcn - ) - - shell.openExternal(url) - - ###* - * @inheritdoc - ### - removePopover: () -> - if @attachedPopover - @attachedPopover.dispose() - @attachedPopover = null diff --git a/lib/Annotations/MethodAnnotationProvider.js b/lib/Annotations/MethodAnnotationProvider.js new file mode 100644 index 00000000..0c919fed --- /dev/null +++ b/lib/Annotations/MethodAnnotationProvider.js @@ -0,0 +1,143 @@ +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * DS205: Consider reworking code to avoid use of IIFEs + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let MethodProvider; +const shell = require('shell'); + +const {Range} = require('atom'); + +const AbstractAnnotationProvider = require('./AbstractAnnotationProvider'); + +module.exports = + +//#* +// Provides annotations for member methods that are overrides or interface implementations. +//# +(MethodProvider = class MethodProvider extends AbstractAnnotationProvider { + /** + * @inheritdoc + */ + registerAnnotations(editor) { + const path = editor.getPath(); + + if (!path) { return null; } + + const successHandler = classInfo => { + if (!classInfo) { return null; } + + return (() => { + const result = []; + for (let name in classInfo.methods) { + const method = classInfo.methods[name]; + if (!method.override && ((method.implementations != null ? method.implementations.length : undefined) === 0)) { continue; } + if (method.declaringStructure.fqcn !== classInfo.fqcn) { continue; } + + const range = new Range([method.startLine - 1, 0], [method.startLine, -1]); + + result.push(this.placeAnnotation(editor, range, this.extractAnnotationInfo(method))); + } + return result; + })(); + }; + + const failureHandler = () => {}; + // Just do nothing. + + const getClassListHandler = classesInEditor => { + const promises = []; + + for (let fqcn in classesInEditor) { + const classInfo = classesInEditor[fqcn]; + promises.push(this.service.getClassInfo(fqcn).then(successHandler, failureHandler)); + } + + return Promise.all(promises); + }; + + return this.service.getClassListForFile(path).then(getClassListHandler, failureHandler); + } + + /** + * Fetches annotation info for the specified context. + * + * @param {Object} context + * + * @return {Object} + */ + extractAnnotationInfo(context) { + let extraData = null; + let tooltipText = ''; + let lineNumberClass = ''; + + if (context.override) { + // NOTE: We deliberately show the declaring class here, not the structure (which could be a trait). However, + // if the method is overriding a trait method from the *same* class, we show the trait name, as it would be + // strange to put an annotation in "Foo" saying "Overrides method from Foo". + let overriddenFromFqcn = context.override.declaringClass.fqcn; + + if (overriddenFromFqcn === context.declaringClass.fqcn) { + overriddenFromFqcn = context.override.declaringStructure.fqcn; + } + + extraData = context.override; + + if (!context.override.wasAbstract) { + lineNumberClass = 'override'; + tooltipText = `Overrides method from ${overriddenFromFqcn}`; + + } else { + lineNumberClass = 'abstract-override'; + tooltipText = `Implements abstract method from ${overriddenFromFqcn}`; + } + + } else { + // NOTE: We deliberately show the declaring class here, not the structure (which could be a trait). + extraData = context.implementations[0]; + lineNumberClass = 'implementations'; + tooltipText = `Implements method for ${extraData.declaringStructure.fqcn}`; + } + + extraData.fqcn = context.fqcn; + + return { + lineNumberClass, + tooltipText, + extraData + }; + } + + /** + * @inheritdoc + */ + handleMouseClick(event, editor, annotationInfo) { + // 'filename' can be false for overrides of members from PHP's built-in classes (e.g. Exception). + if (annotationInfo.extraData.declaringStructure.filename) { + return atom.workspace.open(annotationInfo.extraData.declaringStructure.filename, { + initialLine : annotationInfo.extraData.declaringStructure.startLineMember - 1, + searchAllPanes : true + }); + + } else { + const url = this.service.getDocumentationUrlForClassMethod( + annotationInfo.extraData.declaringStructure.fqcn, + annotationInfo.extraData.fqcn + ); + + return shell.openExternal(url); + } + } + + /** + * @inheritdoc + */ + removePopover() { + if (this.attachedPopover) { + this.attachedPopover.dispose(); + return this.attachedPopover = null; + } + } +}); diff --git a/lib/Annotations/PropertyAnnotationProvider.coffee b/lib/Annotations/PropertyAnnotationProvider.coffee deleted file mode 100644 index 491e3e73..00000000 --- a/lib/Annotations/PropertyAnnotationProvider.coffee +++ /dev/null @@ -1,82 +0,0 @@ -{Range} = require 'atom' - -AbstractAnnotationProvider = require './AbstractAnnotationProvider' - -module.exports = - -##* -# Provides annotations for member properties that are overrides. -## -class MethodProvider extends AbstractAnnotationProvider - ###* - * @inheritdoc - ### - registerAnnotations: (editor) -> - path = editor.getPath() - - return null if not path - - successHandler = (classInfo) => - return null if not classInfo - - for name, property of classInfo.properties - continue if not property.override - continue if property.declaringStructure.fqcn != classInfo.fqcn - - range = new Range([property.startLine - 1, 0], [property.startLine, -1]) - - @placeAnnotation(editor, range, @extractAnnotationInfo(property)) - - failureHandler = () => - # Just do nothing. - - getClassListHandler = (classesInEditor) => - promises = [] - - for fqcn, classInfo of classesInEditor - promises.push @service.getClassInfo(fqcn).then(successHandler, failureHandler) - - return Promise.all(promises) - - return @service.getClassListForFile(path).then(getClassListHandler, failureHandler) - - ###* - * Fetches annotation info for the specified context. - * - * @param {Object} context - * - * @return {Object} - ### - extractAnnotationInfo: (context) -> - # NOTE: We deliberately show the declaring class here, not the structure (which could be a trait). However, - # if the method is overriding a trait method from the *same* class, we show the trait name, as it would be - # strange to put an annotation in "Foo" saying "Overrides method from Foo". - overriddenFromFqcn = context.override.declaringClass.fqcn - - if overriddenFromFqcn == context.declaringClass.fqcn - overriddenFromFqcn = context.override.declaringStructure.fqcn - - return { - lineNumberClass : 'override' - tooltipText : 'Overrides property from ' + overriddenFromFqcn - extraData : context.override - } - - ###* - * @inheritdoc - ### - handleMouseClick: (event, editor, annotationInfo) -> - # 'filename' can be false for overrides of members from PHP's built-in classes (e.g. Exception). - if annotationInfo.extraData.declaringStructure.filename - atom.workspace.open(annotationInfo.extraData.declaringStructure.filename, { - initialLine : annotationInfo.extraData.declaringStructure.startLineMember - 1, - searchAllPanes : true - }) - - ###* - * @inheritdoc - ### - removePopover: () -> - if @attachedPopover - @attachedPopover.dispose() - @attachedPopover = null diff --git a/lib/Annotations/PropertyAnnotationProvider.js b/lib/Annotations/PropertyAnnotationProvider.js new file mode 100644 index 00000000..a445637f --- /dev/null +++ b/lib/Annotations/PropertyAnnotationProvider.js @@ -0,0 +1,107 @@ +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * DS205: Consider reworking code to avoid use of IIFEs + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let MethodProvider; +const {Range} = require('atom'); + +const AbstractAnnotationProvider = require('./AbstractAnnotationProvider'); + +module.exports = + +//#* +// Provides annotations for member properties that are overrides. +//# +(MethodProvider = class MethodProvider extends AbstractAnnotationProvider { + /** + * @inheritdoc + */ + registerAnnotations(editor) { + const path = editor.getPath(); + + if (!path) { return null; } + + const successHandler = classInfo => { + if (!classInfo) { return null; } + + return (() => { + const result = []; + for (let name in classInfo.properties) { + const property = classInfo.properties[name]; + if (!property.override) { continue; } + if (property.declaringStructure.fqcn !== classInfo.fqcn) { continue; } + + const range = new Range([property.startLine - 1, 0], [property.startLine, -1]); + + result.push(this.placeAnnotation(editor, range, this.extractAnnotationInfo(property))); + } + return result; + })(); + }; + + const failureHandler = () => {}; + // Just do nothing. + + const getClassListHandler = classesInEditor => { + const promises = []; + + for (let fqcn in classesInEditor) { + const classInfo = classesInEditor[fqcn]; + promises.push(this.service.getClassInfo(fqcn).then(successHandler, failureHandler)); + } + + return Promise.all(promises); + }; + + return this.service.getClassListForFile(path).then(getClassListHandler, failureHandler); + } + + /** + * Fetches annotation info for the specified context. + * + * @param {Object} context + * + * @return {Object} + */ + extractAnnotationInfo(context) { + // NOTE: We deliberately show the declaring class here, not the structure (which could be a trait). However, + // if the method is overriding a trait method from the *same* class, we show the trait name, as it would be + // strange to put an annotation in "Foo" saying "Overrides method from Foo". + let overriddenFromFqcn = context.override.declaringClass.fqcn; + + if (overriddenFromFqcn === context.declaringClass.fqcn) { + overriddenFromFqcn = context.override.declaringStructure.fqcn; + } + + return { + lineNumberClass : 'override', + tooltipText : `Overrides property from ${overriddenFromFqcn}`, + extraData : context.override + }; + } + + /** + * @inheritdoc + */ + handleMouseClick(event, editor, annotationInfo) { + // 'filename' can be false for overrides of members from PHP's built-in classes (e.g. Exception). + if (annotationInfo.extraData.declaringStructure.filename) { + return atom.workspace.open(annotationInfo.extraData.declaringStructure.filename, { + initialLine : annotationInfo.extraData.declaringStructure.startLineMember - 1, + searchAllPanes : true + }); + } + } + + /** + * @inheritdoc + */ + removePopover() { + if (this.attachedPopover) { + this.attachedPopover.dispose(); + return this.attachedPopover = null; + } + } +}); diff --git a/lib/AtomConfig.coffee b/lib/AtomConfig.coffee deleted file mode 100644 index 4903a8d1..00000000 --- a/lib/AtomConfig.coffee +++ /dev/null @@ -1,97 +0,0 @@ -path = require 'path' -process = require 'process' -mkdirp = require 'mkdirp' - -Config = require './Config' - -module.exports = - -##* -# Config that retrieves its settings from Atom's config. -## -class AtomConfig extends Config - ###* - * The name of the package to use when searching for settings. - ### - packageName: null - - ###* - * @var {Array} - ### - configurableProperties: null - - ###* - * @inheritdoc - ### - constructor: (@packageName) -> - @configurableProperties = [ - 'core.phpExecutionType' - 'core.phpCommand' - 'core.memoryLimit' - 'core.additionalDockerVolumes' - 'general.indexContinuously' - 'general.additionalIndexingDelay' - 'datatips.enable' - 'signatureHelp.enable' - 'gotoDefinition.enable' - 'autocompletion.enable' - 'annotations.enable' - 'refactoring.enable' - 'linting.enable' - 'linting.showUnknownClasses' - 'linting.showUnknownMembers' - 'linting.showUnknownGlobalFunctions' - 'linting.showUnknownGlobalConstants' - 'linting.showUnusedUseStatements' - 'linting.showMissingDocs' - 'linting.validateDocblockCorrectness' - ] - - super() - - @attachListeners() - - ###* - * @inheritdoc - ### - load: () -> - @set('storagePath', @getPathToStorageFolderInRidiculousWay()) - - for property in @configurableProperties - @set(property, atom.config.get("#{@packageName}.#{property}")) - - ###* - * Attaches listeners to listen to Atom configuration changes. - ### - attachListeners: () -> - for property in @configurableProperties - # Hmmm, I thought CoffeeScript automatically solved these variable copy bugs with function creation in - # loops... - callback = ((propertyCopy, data) -> - @set(propertyCopy, data.newValue) - ).bind(this, property) - - atom.config.onDidChange("#{@packageName}.#{property}", callback) - - ###* - * @return {String} - ### - getPathToStorageFolderInRidiculousWay: () -> - # NOTE: Apparently process.env.ATOM_HOME is not always set for whatever reason and this ridiculous workaround - # is needed to fetch an OS-compliant location to store application data. - baseFolder = null - - if process.env.APPDATA - baseFolder = process.env.APPDATA - - else if process.platform == 'darwin' - baseFolder = process.env.HOME + '/Library/Preferences' - - else - baseFolder = process.env.HOME + '/.cache' - - packageFolder = baseFolder + path.sep + @packageName - - mkdirp.sync(packageFolder) - - return packageFolder diff --git a/lib/AtomConfig.js b/lib/AtomConfig.js new file mode 100644 index 00000000..d98f5775 --- /dev/null +++ b/lib/AtomConfig.js @@ -0,0 +1,124 @@ +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS205: Consider reworking code to avoid use of IIFEs + * DS206: Consider reworking classes to avoid initClass + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let AtomConfig; +const path = require('path'); +const process = require('process'); +const mkdirp = require('mkdirp'); + +const Config = require('./Config'); + +module.exports = + +//#* +// Config that retrieves its settings from Atom's config. +//# +(AtomConfig = (function() { + AtomConfig = class AtomConfig extends Config { + static initClass() { + /** + * The name of the package to use when searching for settings. + */ + this.prototype.packageName = null; + + /** + * @var {Array} + */ + this.prototype.configurableProperties = null; + } + + /** + * @inheritdoc + */ + constructor(packageName) { + super(); + + this.packageName = packageName; + this.configurableProperties = [ + 'core.phpExecutionType', + 'core.phpCommand', + 'core.memoryLimit', + 'core.additionalDockerVolumes', + 'general.indexContinuously', + 'general.additionalIndexingDelay', + 'datatips.enable', + 'signatureHelp.enable', + 'gotoDefinition.enable', + 'autocompletion.enable', + 'annotations.enable', + 'refactoring.enable', + 'linting.enable', + 'linting.showUnknownClasses', + 'linting.showUnknownMembers', + 'linting.showUnknownGlobalFunctions', + 'linting.showUnknownGlobalConstants', + 'linting.showUnusedUseStatements', + 'linting.showMissingDocs', + 'linting.validateDocblockCorrectness' + ]; + + this.attachListeners(); + } + + /** + * @inheritdoc + */ + load() { + this.set('storagePath', this.getPathToStorageFolderInRidiculousWay()); + + return Array.from(this.configurableProperties).map((property) => + this.set(property, atom.config.get(`${this.packageName}.${property}`))); + } + + /** + * Attaches listeners to listen to Atom configuration changes. + */ + attachListeners() { + return (() => { + const result = []; + for (let property of Array.from(this.configurableProperties)) { + // Hmmm, I thought CoffeeScript automatically solved these variable copy bugs with function creation in + // loops... + const callback = (function(propertyCopy, data) { + return this.set(propertyCopy, data.newValue); + }).bind(this, property); + + result.push(atom.config.onDidChange(`${this.packageName}.${property}`, callback)); + } + return result; + })(); + } + + /** + * @return {String} + */ + getPathToStorageFolderInRidiculousWay() { + // NOTE: Apparently process.env.ATOM_HOME is not always set for whatever reason and this ridiculous workaround + // is needed to fetch an OS-compliant location to store application data. + let baseFolder = null; + + if (process.env.APPDATA) { + baseFolder = process.env.APPDATA; + + } else if (process.platform === 'darwin') { + baseFolder = process.env.HOME + '/Library/Preferences'; + + } else { + baseFolder = process.env.HOME + '/.cache'; + } + + const packageFolder = baseFolder + path.sep + this.packageName; + + mkdirp.sync(packageFolder); + + return packageFolder; + } + }; + AtomConfig.initClass(); + return AtomConfig; +})()); diff --git a/lib/AutocompletionProvider.coffee b/lib/AutocompletionProvider.coffee deleted file mode 100644 index ade7adcd..00000000 --- a/lib/AutocompletionProvider.coffee +++ /dev/null @@ -1,300 +0,0 @@ -{Disposable, CompositeDisposable} = require 'atom' - -module.exports = - -##* -# Base class for providers. -## -class AbstractProvider - ###* - * The class selectors for which autocompletion triggers. - * - * @var {String} - ### - scopeSelector: '.source.php' - - ###* - * The inclusion priority of the provider. - * - * @var {Number} - ### - inclusionPriority: 1 - - ###* - * Whether to let autocomplete-plus handle the actual filtering, that way we don't need to manually filter (e.g. - * using fuzzaldrin) ourselves and the user can configure filtering settings on the base package. - * - * Set to false as the core does the filtering to avoid sending a large amount of suggestions back over the socket. - * - * @var {Boolean} - ### - filterSuggestions: false - - ###* - * The class selectors autocompletion is explicitly disabled for (overrules the {@see scopeSelector}). - * - * @var {String} - ### - disableForScopeSelector: null - - ###* - * Whether to exclude providers with a lower priority. - * - * This ensures the default, built-in suggestions from the language-php package do not show up. - * - * @var {Boolean} - ### - excludeLowerPriority: true - - ###* - * The service (that can be used to query the source code and contains utility methods). - * - * @var {Object} - ### - service: null - - ###* - * @var {CompositeDisposable} - ### - disposables: null - - ###* - * @var {CancellablePromise} - ### - pendingRequestPromise: null - - ###* - * Initializes this provider. - * - * @param {mixed} service - ### - activate: (@service) -> - dependentPackage = 'language-php' - - @disposables = new CompositeDisposable() - - # It could be that the dependent package is already active, in that case we can continue immediately. If not, - # we'll need to wait for the listener to be invoked - if atom.packages.isPackageActive(dependentPackage) - @doActualInitialization() - - @disposables.add atom.packages.onDidActivatePackage (packageData) => - return if packageData.name != dependentPackage - - @doActualInitialization() - - @disposables.add atom.packages.onDidDeactivatePackage (packageData) => - return if packageData.name != dependentPackage - - @deactivate() - - ###* - * Does the actual initialization. - ### - doActualInitialization: () -> - @disposables.add atom.workspace.observeTextEditors (editor) => - if /text.html.php$/.test(editor.getGrammar().scopeName) - @registerEvents(editor) - - # When you go back to only have one pane the events are lost, so need to re-register. - @disposables.add atom.workspace.onDidDestroyPane (pane) => - panes = atom.workspace.getPanes() - - if panes.length == 1 - @registerEventsForPane(panes[0]) - - # Having to re-register events as when a new pane is created the old panes lose the events. - @disposables.add atom.workspace.onDidAddPane (observedPane) => - panes = atom.workspace.getPanes() - - for pane in panes - if pane != observedPane - @registerEventsForPane(pane) - - @disposables.add atom.workspace.onDidStopChangingActivePaneItem (item) => - @stopPendingRequests() - - @disposables.add atom.workspace.onDidChangeActiveTextEditor (item) => - @stopPendingRequests() - - ###* - * Registers the necessary event handlers for the editors in the specified pane. - * - * @param {Pane} pane - ### - registerEventsForPane: (pane) -> - for paneItem in pane.items - if atom.workspace.isTextEditor(paneItem) - if /text.html.php$/.test(paneItem.getGrammar().scopeName) - @registerEvents(paneItem) - - ###* - * Registers the necessary event handlers. - * - * @param {TextEditor} editor TextEditor to register events to. - ### - registerEvents: (editor) -> - @disposables.add editor.onDidChangeCursorPosition (event) => - # Don't trigger whilst actually typing, just when moving. - return if event.textChanged == true - - @onChangeCursorPosition(editor) - - ###* - * @param {TextEditor} editor - ### - onChangeCursorPosition: (editor) -> - @stopPendingRequests() - - ###* - * Deactives the provider. - ### - deactivate: () -> - @disposables.dispose() - - @stopPendingRequests() - - ###* - * Entry point for all requests from autocomplete-plus. - * - * @param {TextEditor} editor - * @param {Point} bufferPosition - * @param {String} scopeDescriptor - * @param {String} prefix - * - * @return {Promise|Array} - ### - getSuggestions: ({editor, bufferPosition, scopeDescriptor, prefix}) -> - @stopPendingRequests() - - return [] if not @service - - successHandler = (suggestions) => - return suggestions.map (suggestion) => - return @getAdaptedSuggestion(suggestion) - - failureHandler = () => - return [] # Just return no suggestions. - - @pendingRequestPromise = @service.autocompleteAt(editor, bufferPosition) - - return @pendingRequestPromise.then(successHandler, failureHandler) - - ###* - * @param {Object} suggestion - * - * @return {Array} - ### - getAdaptedSuggestion: (suggestion) -> - adaptedSuggestion = { - text : suggestion.filterText - snippet : suggestion.insertText.replace(/\\/g, '\\\\') - type : suggestion.kind - displayText : suggestion.label - leftLabelHTML : @getSuggestionLeftLabel(suggestion) - rightLabelHTML : @getSuggestionRightLabel(suggestion) - description : suggestion.documentation - className : 'php-ide-serenata-autocompletion-suggestion' + if suggestion.isDeprecated then ' php-ide-serenata-autocompletion-strike' else '' - - extraData: - additionalTextEdits: suggestion.additionalTextEdits - } - - # TODO: Better would be to support the textEdit property sent brck by the core's suggestions via - # onDidInsertSuggestion. - if suggestion.extraData?.prefix? - adaptedSuggestion.replacementPrefix = suggestion.extraData.prefix - - return adaptedSuggestion - - ###* - * Builds the right label for a PHP function or method. - * - * @param {Object} suggestion Information about the function or method. - * - * @return {String} - ### - getSuggestionLeftLabel: (suggestion) -> - leftLabel = '' - - if suggestion.extraData?.protectionLevel == 'public' - leftLabel += ' ' - - else if suggestion.extraData?.protectionLevel == 'protecetd' - leftLabel += ' ' - - else if suggestion.extraData?.protectionLevel == 'private' - leftLabel += ' ' - - if suggestion.extraData?.returnTypes? - leftLabel += @getTypeSpecificationFromTypeArray(suggestion.extraData.returnTypes.split('|')) - - return leftLabel - - ###* - * Builds the right label for a PHP function or method. - * - * @param {Object} suggestion Information about the function or method. - * - * @return {String} - ### - getSuggestionRightLabel: (suggestion) -> - return null if not suggestion.extraData?.declaringStructure? - - # Determine the short name of the location where this item is defined. - declaringStructureShortName = '' - - if suggestion.extraData.declaringStructure and suggestion.extraData.declaringStructure.fqcn - return @getClassShortName(suggestion.extraData.declaringStructure.fqcn) - - return declaringStructureShortName - - ###* - * @param {Array} typeArray - * - * @return {String} - ### - getTypeSpecificationFromTypeArray: (typeArray) -> - typeNames = typeArray.map (type) => - return @getClassShortName(type) - - return typeNames.join('|') - - ###* - * Retrieves the short name for the specified class name (i.e. the last segment, without the class namespace). - * - * @param {String} className - * - * @return {String} - ### - getClassShortName: (className) -> - return null if not className - - parts = className.split('\\') - return parts.pop() - - ###* - * Called when the user confirms an autocompletion suggestion. - * - * @param {TextEditor} editor - * @param {Position} triggerPosition - * @param {Object} suggestion - ### - onDidInsertSuggestion: ({editor, triggerPosition, suggestion}) -> - return unless suggestion.extraData.additionalTextEdits?.length > 0 - - editor.transact () => - for additionalTextEdit in suggestion.extraData.additionalTextEdits - editor.setTextInBufferRange([ - [additionalTextEdit.range.start.line, additionalTextEdit.range.start.character], - [additionalTextEdit.range.end.line, additionalTextEdit.range.end.character]], - additionalTextEdit.newText - ) - - ###* - * Stops any pending requests. - ### - stopPendingRequests: () -> - if @pendingRequestPromise? - @pendingRequestPromise.cancel() - @pendingRequestPromise = null diff --git a/lib/AutocompletionProvider.js b/lib/AutocompletionProvider.js new file mode 100644 index 00000000..a37e2505 --- /dev/null +++ b/lib/AutocompletionProvider.js @@ -0,0 +1,381 @@ +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS205: Consider reworking code to avoid use of IIFEs + * DS206: Consider reworking classes to avoid initClass + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let AbstractProvider; +const {Disposable, CompositeDisposable} = require('atom'); + +module.exports = + +//#* +// Base class for providers. +//# +(AbstractProvider = (function() { + AbstractProvider = class AbstractProvider { + static initClass() { + /** + * The class selectors for which autocompletion triggers. + * + * @var {String} + */ + this.prototype.scopeSelector = '.source.php'; + + /** + * The inclusion priority of the provider. + * + * @var {Number} + */ + this.prototype.inclusionPriority = 1; + + /** + * Whether to let autocomplete-plus handle the actual filtering, that way we don't need to manually filter (e.g. + * using fuzzaldrin) ourselves and the user can configure filtering settings on the base package. + * + * Set to false as the core does the filtering to avoid sending a large amount of suggestions back over the socket. + * + * @var {Boolean} + */ + this.prototype.filterSuggestions = false; + + /** + * The class selectors autocompletion is explicitly disabled for (overrules the {@see scopeSelector}). + * + * @var {String} + */ + this.prototype.disableForScopeSelector = null; + + /** + * Whether to exclude providers with a lower priority. + * + * This ensures the default, built-in suggestions from the language-php package do not show up. + * + * @var {Boolean} + */ + this.prototype.excludeLowerPriority = true; + + /** + * The service (that can be used to query the source code and contains utility methods). + * + * @var {Object} + */ + this.prototype.service = null; + + /** + * @var {CompositeDisposable} + */ + this.prototype.disposables = null; + + /** + * @var {CancellablePromise} + */ + this.prototype.pendingRequestPromise = null; + } + + /** + * Initializes this provider. + * + * @param {mixed} service + */ + activate(service) { + this.service = service; + const dependentPackage = 'language-php'; + + this.disposables = new CompositeDisposable(); + + // It could be that the dependent package is already active, in that case we can continue immediately. If not, + // we'll need to wait for the listener to be invoked + if (atom.packages.isPackageActive(dependentPackage)) { + this.doActualInitialization(); + } + + this.disposables.add(atom.packages.onDidActivatePackage(packageData => { + if (packageData.name !== dependentPackage) { return; } + + return this.doActualInitialization(); + }) + ); + + return this.disposables.add(atom.packages.onDidDeactivatePackage(packageData => { + if (packageData.name !== dependentPackage) { return; } + + return this.deactivate(); + }) + ); + } + + /** + * Does the actual initialization. + */ + doActualInitialization() { + this.disposables.add(atom.workspace.observeTextEditors(editor => { + if (/text.html.php$/.test(editor.getGrammar().scopeName)) { + return this.registerEvents(editor); + } + }) + ); + + // When you go back to only have one pane the events are lost, so need to re-register. + this.disposables.add(atom.workspace.onDidDestroyPane(pane => { + const panes = atom.workspace.getPanes(); + + if (panes.length === 1) { + return this.registerEventsForPane(panes[0]); + } + }) + ); + + // Having to re-register events as when a new pane is created the old panes lose the events. + this.disposables.add(atom.workspace.onDidAddPane(observedPane => { + const panes = atom.workspace.getPanes(); + + return (() => { + const result = []; + for (let pane of Array.from(panes)) { + if (pane !== observedPane) { + result.push(this.registerEventsForPane(pane)); + } else { + result.push(undefined); + } + } + return result; + })(); + }) + ); + + this.disposables.add(atom.workspace.onDidStopChangingActivePaneItem(item => { + return this.stopPendingRequests(); + }) + ); + + return this.disposables.add(atom.workspace.onDidChangeActiveTextEditor(item => { + return this.stopPendingRequests(); + }) + ); + } + + /** + * Registers the necessary event handlers for the editors in the specified pane. + * + * @param {Pane} pane + */ + registerEventsForPane(pane) { + return (() => { + const result = []; + for (let paneItem of Array.from(pane.items)) { + if (atom.workspace.isTextEditor(paneItem)) { + if (/text.html.php$/.test(paneItem.getGrammar().scopeName)) { + result.push(this.registerEvents(paneItem)); + } else { + result.push(undefined); + } + } else { + result.push(undefined); + } + } + return result; + })(); + } + + /** + * Registers the necessary event handlers. + * + * @param {TextEditor} editor TextEditor to register events to. + */ + registerEvents(editor) { + return this.disposables.add(editor.onDidChangeCursorPosition(event => { + // Don't trigger whilst actually typing, just when moving. + if (event.textChanged === true) { return; } + + return this.onChangeCursorPosition(editor); + }) + ); + } + + /** + * @param {TextEditor} editor + */ + onChangeCursorPosition(editor) { + return this.stopPendingRequests(); + } + + /** + * Deactives the provider. + */ + deactivate() { + this.disposables.dispose(); + + return this.stopPendingRequests(); + } + + /** + * Entry point for all requests from autocomplete-plus. + * + * @param {TextEditor} editor + * @param {Point} bufferPosition + * @param {String} scopeDescriptor + * @param {String} prefix + * + * @return {Promise|Array} + */ + getSuggestions({editor, bufferPosition, scopeDescriptor, prefix}) { + this.stopPendingRequests(); + + if (!this.service) { return []; } + + const successHandler = suggestions => { + return suggestions.map(suggestion => { + return this.getAdaptedSuggestion(suggestion); + }); + }; + + const failureHandler = () => { + return []; // Just return no suggestions. + }; + + this.pendingRequestPromise = this.service.autocompleteAt(editor, bufferPosition); + + return this.pendingRequestPromise.then(successHandler, failureHandler); + } + + /** + * @param {Object} suggestion + * + * @return {Array} + */ + getAdaptedSuggestion(suggestion) { + const adaptedSuggestion = { + text : suggestion.filterText, + snippet : suggestion.insertText.replace(/\\/g, '\\\\'), + type : suggestion.kind, + displayText : suggestion.label, + leftLabelHTML : this.getSuggestionLeftLabel(suggestion), + rightLabelHTML : this.getSuggestionRightLabel(suggestion), + description : suggestion.documentation, + className : `php-ide-serenata-autocompletion-suggestion${suggestion.isDeprecated ? ' php-ide-serenata-autocompletion-strike' : ''}`, + + extraData: { + additionalTextEdits: suggestion.additionalTextEdits + } + }; + + // TODO: Better would be to support the textEdit property sent brck by the core's suggestions via + // onDidInsertSuggestion. + if ((suggestion.extraData != null ? suggestion.extraData.prefix : undefined) != null) { + adaptedSuggestion.replacementPrefix = suggestion.extraData.prefix; + } + + return adaptedSuggestion; + } + + /** + * Builds the right label for a PHP function or method. + * + * @param {Object} suggestion Information about the function or method. + * + * @return {String} + */ + getSuggestionLeftLabel(suggestion) { + let leftLabel = ''; + + if ((suggestion.extraData != null ? suggestion.extraData.protectionLevel : undefined) === 'public') { + leftLabel += ' '; + + } else if ((suggestion.extraData != null ? suggestion.extraData.protectionLevel : undefined) === 'protecetd') { + leftLabel += ' '; + + } else if ((suggestion.extraData != null ? suggestion.extraData.protectionLevel : undefined) === 'private') { + leftLabel += ' '; + } + + if ((suggestion.extraData != null ? suggestion.extraData.returnTypes : undefined) != null) { + leftLabel += this.getTypeSpecificationFromTypeArray(suggestion.extraData.returnTypes.split('|')); + } + + return leftLabel; + } + + /** + * Builds the right label for a PHP function or method. + * + * @param {Object} suggestion Information about the function or method. + * + * @return {String} + */ + getSuggestionRightLabel(suggestion) { + if (((suggestion.extraData != null ? suggestion.extraData.declaringStructure : undefined) == null)) { return null; } + + // Determine the short name of the location where this item is defined. + const declaringStructureShortName = ''; + + if (suggestion.extraData.declaringStructure && suggestion.extraData.declaringStructure.fqcn) { + return this.getClassShortName(suggestion.extraData.declaringStructure.fqcn); + } + + return declaringStructureShortName; + } + + /** + * @param {Array} typeArray + * + * @return {String} + */ + getTypeSpecificationFromTypeArray(typeArray) { + const typeNames = typeArray.map(type => { + return this.getClassShortName(type); + }); + + return typeNames.join('|'); + } + + /** + * Retrieves the short name for the specified class name (i.e. the last segment, without the class namespace). + * + * @param {String} className + * + * @return {String} + */ + getClassShortName(className) { + if (!className) { return null; } + + const parts = className.split('\\'); + return parts.pop(); + } + + /** + * Called when the user confirms an autocompletion suggestion. + * + * @param {TextEditor} editor + * @param {Position} triggerPosition + * @param {Object} suggestion + */ + onDidInsertSuggestion({editor, triggerPosition, suggestion}) { + if (!((suggestion.extraData.additionalTextEdits != null ? suggestion.extraData.additionalTextEdits.length : undefined) > 0)) { return; } + + return editor.transact(() => { + return Array.from(suggestion.extraData.additionalTextEdits).map((additionalTextEdit) => + editor.setTextInBufferRange([ + [additionalTextEdit.range.start.line, additionalTextEdit.range.start.character], + [additionalTextEdit.range.end.line, additionalTextEdit.range.end.character]], + additionalTextEdit.newText + )); + }); + } + + /** + * Stops any pending requests. + */ + stopPendingRequests() { + if (this.pendingRequestPromise != null) { + this.pendingRequestPromise.cancel(); + return this.pendingRequestPromise = null; + } + } + }; + AbstractProvider.initClass(); + return AbstractProvider; +})()); diff --git a/lib/ComposerService.coffee b/lib/ComposerService.coffee deleted file mode 100644 index 04b24b8e..00000000 --- a/lib/ComposerService.coffee +++ /dev/null @@ -1,154 +0,0 @@ -fs = require 'fs' -path = require 'path' -download = require 'download' -child_process = require 'child_process' - -module.exports = - -##* -# Handles usage of Composer (PHP package manager). -## -class ComposerService - ###* - * The commit to download from the Composer repository. - * - * Currently set to version 1.6.4. - * - * @see https://getcomposer.org/doc/faqs/how-to-install-composer-programmatically.md - * - * @var {String} - ### - COMPOSER_COMMIT: '01a340a59c504c900251e3e189d0cb2008e888c6' - - ###* - * @var {Object} - ### - phpInvoker: null - - ###* - * @var {String} - ### - folder: null - - ###* - * @param {Object} phpInvoker - * @param {String} folder - ### - constructor: (@phpInvoker, @folder) -> - - ###* - * @param {Array} parameters - * @param {String|null} workingDirectory - * - * @return {Promise} - ### - run: (parameters, workingDirectory = null) -> - return @installIfNecessary().then () => - options = {} - - if workingDirectory? - options.cwd = workingDirectory - - return new Promise (resolve, reject) => - process = @phpInvoker.invoke([@getPath()].concat(parameters), [], options) - - process.stdout.on 'data', (data) => - console.info('Composer has something to say:', data.toString()) - - process.stderr.on 'data', (data) => - # Valid information is also sent via STDERR, see also - # https://github.com/composer/composer/issues/3787#issuecomment-76167739 - console.info('Composer has something to say:', data.toString()) - - process.on 'close', (code) => - console.debug('Composer exited with status code:', code) - - if code != 0 - reject() - - else - resolve() - - ###* - * @return {Promise} - ### - installIfNecessary: () -> - if @isInstalled() - return new Promise (resolve, reject) -> - resolve() - - return @install() - - ###* - * @param {Boolean} - ### - isInstalled: () -> - return true if fs.existsSync(@getPath()) - - ###* - * @return {Promise} - ### - install: () -> - @download().then () => - parameters = [ - @getInstallerFileFilePath(), - '--install-dir=' + @phpInvoker.normalizePlatformAndRuntimePath(@getInstallerFilePath()), - '--filename=' + @getFileName() - ] - - return new Promise (resolve, reject) => - process = @phpInvoker.invoke(parameters) - - process.stdout.on 'data', (data) => - console.debug('Composer installer has something to say:', data.toString()) - - process.stderr.on 'data', (data) => - console.warn('Composer installer has errors to report:', data.toString()) - - process.on 'close', (code) => - console.debug('Composer installer exited with status code:', code) - - if code != 0 - reject() - - else - resolve() - - ###* - * @return {Promise} - ### - download: () -> - return download( - 'https://raw.githubusercontent.com/composer/getcomposer.org/' + @COMPOSER_COMMIT + '/web/installer', - @getInstallerFilePath() - ) - - ###* - * @return {String} - ### - getInstallerFilePath: () -> - return @folder - - ###* - * @return {String} - ### - getInstallerFileFileName: () -> - return 'installer' - - ###* - * @return {String} - ### - getInstallerFileFilePath: () -> - return @phpInvoker.normalizePlatformAndRuntimePath(path.join(@getInstallerFilePath(), @getInstallerFileFileName())) - - ###* - * @return {String} - ### - getPath: () -> - return @phpInvoker.normalizePlatformAndRuntimePath(path.join(@getInstallerFilePath(), @getFileName())) - - ###* - * @return {String} - ### - getFileName: () -> - return 'composer.phar' diff --git a/lib/ComposerService.js b/lib/ComposerService.js new file mode 100644 index 00000000..9d46fd68 --- /dev/null +++ b/lib/ComposerService.js @@ -0,0 +1,197 @@ +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * DS206: Consider reworking classes to avoid initClass + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let ComposerService; +const fs = require('fs'); +const path = require('path'); +const download = require('download'); +const child_process = require('child_process'); + +module.exports = + +//#* +// Handles usage of Composer (PHP package manager). +//# +(ComposerService = (function() { + ComposerService = class ComposerService { + static initClass() { + /** + * The commit to download from the Composer repository. + * + * Currently set to version 1.6.4. + * + * @see https://getcomposer.org/doc/faqs/how-to-install-composer-programmatically.md + * + * @var {String} + */ + this.prototype.COMPOSER_COMMIT = '01a340a59c504c900251e3e189d0cb2008e888c6'; + + /** + * @var {Object} + */ + this.prototype.phpInvoker = null; + + /** + * @var {String} + */ + this.prototype.folder = null; + } + + /** + * @param {Object} phpInvoker + * @param {String} folder + */ + constructor(phpInvoker, folder) { + this.phpInvoker = phpInvoker; + this.folder = folder; + } + + /** + * @param {Array} parameters + * @param {String|null} workingDirectory + * + * @return {Promise} + */ + run(parameters, workingDirectory = null) { + return this.installIfNecessary().then(() => { + const options = {}; + + if (workingDirectory != null) { + options.cwd = workingDirectory; + } + + return new Promise((resolve, reject) => { + const process = this.phpInvoker.invoke([this.getPath()].concat(parameters), [], options); + + process.stdout.on('data', data => { + return console.info('Composer has something to say:', data.toString()); + }); + + process.stderr.on('data', data => { + // Valid information is also sent via STDERR, see also + // https://github.com/composer/composer/issues/3787#issuecomment-76167739 + return console.info('Composer has something to say:', data.toString()); + }); + + return process.on('close', code => { + console.debug('Composer exited with status code:', code); + + if (code !== 0) { + return reject(); + + } else { + return resolve(); + } + }); + }); + }); + } + + /** + * @return {Promise} + */ + installIfNecessary() { + if (this.isInstalled()) { + return new Promise(function(resolve, reject) { + return resolve(); + }); + } + + return this.install(); + } + + /** + * @param {Boolean} + */ + isInstalled() { + if (fs.existsSync(this.getPath())) { return true; } + } + + /** + * @return {Promise} + */ + install() { + return this.download().then(() => { + const parameters = [ + this.getInstallerFileFilePath(), + `--install-dir=${this.phpInvoker.normalizePlatformAndRuntimePath(this.getInstallerFilePath())}`, + `--filename=${this.getFileName()}` + ]; + + return new Promise((resolve, reject) => { + const process = this.phpInvoker.invoke(parameters); + + process.stdout.on('data', data => { + return console.debug('Composer installer has something to say:', data.toString()); + }); + + process.stderr.on('data', data => { + return console.warn('Composer installer has errors to report:', data.toString()); + }); + + return process.on('close', code => { + console.debug('Composer installer exited with status code:', code); + + if (code !== 0) { + return reject(); + + } else { + return resolve(); + } + }); + }); + }); + } + + /** + * @return {Promise} + */ + download() { + return download( + `https://raw.githubusercontent.com/composer/getcomposer.org/${this.COMPOSER_COMMIT}/web/installer`, + this.getInstallerFilePath() + ); + } + + /** + * @return {String} + */ + getInstallerFilePath() { + return this.folder; + } + + /** + * @return {String} + */ + getInstallerFileFileName() { + return 'installer'; + } + + /** + * @return {String} + */ + getInstallerFileFilePath() { + return this.phpInvoker.normalizePlatformAndRuntimePath(path.join(this.getInstallerFilePath(), this.getInstallerFileFileName())); + } + + /** + * @return {String} + */ + getPath() { + return this.phpInvoker.normalizePlatformAndRuntimePath(path.join(this.getInstallerFilePath(), this.getFileName())); + } + + /** + * @return {String} + */ + getFileName() { + return 'composer.phar'; + } + }; + ComposerService.initClass(); + return ComposerService; +})()); diff --git a/lib/Config.coffee b/lib/Config.coffee deleted file mode 100644 index feb358a5..00000000 --- a/lib/Config.coffee +++ /dev/null @@ -1,113 +0,0 @@ -fs = require 'fs' - -module.exports = - -##* -# Abstract base class for managing configurations. -## -class Config - ###* - * Raw configuration object. - ### - data: null - - ###* - * Array of change listeners. - ### - listeners: null - - ###* - * Constructor. - ### - constructor: () -> - @listeners = {} - - @data = - core: - phpExecutionType : 'host' - phpCommand : null - memoryLimit : 512 - additionalDockerVolumes : [] - - general: - indexContinuously : true - additionalIndexingDelay : 200 - - datatips: - enable : true - - signatureHelp: - enable : true - - gotoDefintion: - enable : true - - autocompletion: - enable : true - - annotations: - enable : true - - refactoring: - enable : true - - linting: - enable : true - showUnknownClasses : true - showUnknownMembers : true - showUnknownGlobalFunctions : true - showUnknownGlobalConstants : true - showUnusedUseStatements : true - showMissingDocs : true - validateDocblockCorrectness : true - - # See also http://www.phpdoc.org/docs/latest/index.html . - phpdoc_base_url : { - prefix: 'http://www.phpdoc.org/docs/latest/references/phpdoc/tags/' - suffix: '.html' - } - - # See also https://secure.php.net/urlhowto.php . - php_documentation_base_urls : { - root : 'https://secure.php.net/' - classes : 'https://secure.php.net/class.' - functions : 'https://secure.php.net/function.' - } - - @load() - - ###* - * Loads the configuration. - ### - load: () -> - throw new Error("This method is abstract and must be implemented!") - - ###* - * Registers a listener that is invoked when the specified property is changed. - ### - onDidChange: (name, callback) -> - if name not of @listeners - @listeners[name] = [] - - @listeners[name].push(callback) - - ###* - * Retrieves the config setting with the specified name. - * - * @return {mixed} - ### - get: (name) -> - return @data[name] - - ###* - * Retrieves the config setting with the specified name. - * - * @param {String} name - * @param {mixed} value - ### - set: (name, value) -> - @data[name] = value - - if name of @listeners - for listener in @listeners[name] - listener(value, name) diff --git a/lib/Config.js b/lib/Config.js new file mode 100644 index 00000000..6c1b4ce6 --- /dev/null +++ b/lib/Config.js @@ -0,0 +1,143 @@ +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS206: Consider reworking classes to avoid initClass + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let Config; +const fs = require('fs'); + +module.exports = + +//#* +// Abstract base class for managing configurations. +//# +(Config = (function() { + Config = class Config { + static initClass() { + /** + * Raw configuration object. + */ + this.prototype.data = null; + + /** + * Array of change listeners. + */ + this.prototype.listeners = null; + } + + /** + * Constructor. + */ + constructor() { + this.listeners = {}; + + this.data = { + core: { + phpExecutionType : 'host', + phpCommand : null, + memoryLimit : 512, + additionalDockerVolumes : [] + }, + + general: { + indexContinuously : true, + additionalIndexingDelay : 200 + }, + + datatips: { + enable : true + }, + + signatureHelp: { + enable : true + }, + + gotoDefintion: { + enable : true + }, + + autocompletion: { + enable : true + }, + + annotations: { + enable : true + }, + + refactoring: { + enable : true + }, + + linting: { + enable : true, + showUnknownClasses : true, + showUnknownMembers : true, + showUnknownGlobalFunctions : true, + showUnknownGlobalConstants : true, + showUnusedUseStatements : true, + showMissingDocs : true, + validateDocblockCorrectness : true + }, + + // See also http://www.phpdoc.org/docs/latest/index.html . + phpdoc_base_url : { + prefix: 'http://www.phpdoc.org/docs/latest/references/phpdoc/tags/', + suffix: '.html' + }, + + // See also https://secure.php.net/urlhowto.php . + php_documentation_base_urls : { + root : 'https://secure.php.net/', + classes : 'https://secure.php.net/class.', + functions : 'https://secure.php.net/function.' + } + }; + } + + /** + * Loads the configuration. + */ + load() { + throw new Error("This method is abstract and must be implemented!"); + } + + /** + * Registers a listener that is invoked when the specified property is changed. + */ + onDidChange(name, callback) { + if (!(name in this.listeners)) { + this.listeners[name] = []; + } + + return this.listeners[name].push(callback); + } + + /** + * Retrieves the config setting with the specified name. + * + * @return {mixed} + */ + get(name) { + return this.data[name]; + } + + /** + * Retrieves the config setting with the specified name. + * + * @param {String} name + * @param {mixed} value + */ + set(name, value) { + this.data[name] = value; + + if (name in this.listeners) { + return Array.from(this.listeners[name]).map((listener) => + listener(value, name)); + } + } + }; + Config.initClass(); + return Config; +})()); diff --git a/lib/ConfigTester.coffee b/lib/ConfigTester.coffee deleted file mode 100644 index 2723c2b7..00000000 --- a/lib/ConfigTester.coffee +++ /dev/null @@ -1,26 +0,0 @@ -child_process = require "child_process" - -module.exports = - -##* -# Tests the user's PHP setup to see if it is properly usable. -## -class ConfigTester - ###* - * Constructor. - * - * @param {PhpInvoker} phpInvoker - ### - constructor: (@phpInvoker) -> - - ###* - * @return {Promise} - ### - test: () -> - return new Promise (resolve, reject) => - process = @phpInvoker.invoke(['-v']) - process.on 'close', (code) => - if code == 0 - resolve(true) - - resolve(false) diff --git a/lib/ConfigTester.js b/lib/ConfigTester.js new file mode 100644 index 00000000..f4078d28 --- /dev/null +++ b/lib/ConfigTester.js @@ -0,0 +1,39 @@ +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let ConfigTester; +const child_process = require("child_process"); + +module.exports = + +//#* +// Tests the user's PHP setup to see if it is properly usable. +//# +(ConfigTester = class ConfigTester { + /** + * Constructor. + * + * @param {PhpInvoker} phpInvoker + */ + constructor(phpInvoker) { + this.phpInvoker = phpInvoker; + } + + /** + * @return {Promise} + */ + test() { + return new Promise((resolve, reject) => { + const process = this.phpInvoker.invoke(['-v']); + return process.on('close', code => { + if (code === 0) { + resolve(true); + } + + return resolve(false); + }); + }); + } +}); diff --git a/lib/CoreManager.coffee b/lib/CoreManager.coffee deleted file mode 100644 index 7bf70e76..00000000 --- a/lib/CoreManager.coffee +++ /dev/null @@ -1,111 +0,0 @@ -fs = require 'fs' -path = require 'path' -rimraf = require 'rimraf' -mkdirp = require 'mkdirp' - -module.exports = - -##* -# Handles management of the (PHP) core that is needed to handle the server side. -## -class CoreManager - ###* - * The commit to download from the Composer repository. - * - * Currently set to version 1.6.3. - * - * @see https://getcomposer.org/doc/faqs/how-to-install-composer-programmatically.md - * - * @var {String} - ### - COMPOSER_COMMIT: 'c1ad3667731e9c5c1a21e5835c7e6a7eedc2e1fe' - - ###* - * @var {String} - ### - COMPOSER_PACKAGE_NAME: 'Serenata/Serenata' - - ###* - * @var {ComposerService} - ### - composerService: null - - ###* - * @var {String} - ### - versionSpecification: null - - ###* - * @var {String} - ### - folder: null - - ###* - * @param {ComposerService} composerService - * @param {String} versionSpecification - * @param {String} folder - ### - constructor: (@composerService, @versionSpecification, @folder) -> - - ###* - * @return {Promise} - ### - install: () -> - @removeExistingFolderIfPresent() - - mkdirp(@getCoreSourcePath()) - - return @composerService.run([ - 'create-project', - @COMPOSER_PACKAGE_NAME, - @composerService.phpInvoker.normalizePlatformAndRuntimePath(@getCoreSourcePath()), - @versionSpecification, - # https://github.com/php-integrator/atom-base/issues/303 - Unfortunately the dist involves using a ZIP on - # Windows, which in turn causes temporary files to be created that exceed the maximum path limit. Hence - # source installation is preferred. - # '--prefer-dist', - '--prefer-source', - '--no-interaction', - '--no-dev', - '--no-progress' - ], @folder) - - ###* - * @return {Boolean} - ### - removeExistingFolderIfPresent: () -> - if fs.existsSync(@getCoreSourcePath()) - rimraf.sync(@getCoreSourcePath()) - - ###* - * @return {Boolean} - ### - isInstalled: () -> - return fs.existsSync(@getComposerLockFilePath()) - - ###* - * @return {String} - ### - getComposerLockFilePath: () -> - return path.join(@getCoreSourcePath(), 'composer.lock') - - ###* - * @return {String} - ### - getCoreSourcePath: () -> - if @folder == null - throw new Error('No folder configured for core installation') - - else if @versionSpecification == null - throw new Error('No folder configured for core installation') - - coreSourcePath = path.join(@folder, @versionSpecification) - - if not coreSourcePath? or coreSourcePath.length == 0 - throw new Error('Failed producing a usable core source folder path') - - if coreSourcePath == '/' - # Can never be too careful with dynamic path generation (and recursive deletes). - throw new Error('Nope, I\'m not going to use your filesystem root') - - return coreSourcePath diff --git a/lib/CoreManager.js b/lib/CoreManager.js new file mode 100644 index 00000000..11ac2856 --- /dev/null +++ b/lib/CoreManager.js @@ -0,0 +1,139 @@ +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * DS206: Consider reworking classes to avoid initClass + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let CoreManager; +const fs = require('fs'); +const path = require('path'); +const rimraf = require('rimraf'); +const mkdirp = require('mkdirp'); + +module.exports = + +//#* +// Handles management of the (PHP) core that is needed to handle the server side. +//# +(CoreManager = (function() { + CoreManager = class CoreManager { + static initClass() { + /** + * The commit to download from the Composer repository. + * + * Currently set to version 1.6.3. + * + * @see https://getcomposer.org/doc/faqs/how-to-install-composer-programmatically.md + * + * @var {String} + */ + this.prototype.COMPOSER_COMMIT = 'c1ad3667731e9c5c1a21e5835c7e6a7eedc2e1fe'; + + /** + * @var {String} + */ + this.prototype.COMPOSER_PACKAGE_NAME = 'Serenata/Serenata'; + + /** + * @var {ComposerService} + */ + this.prototype.composerService = null; + + /** + * @var {String} + */ + this.prototype.versionSpecification = null; + + /** + * @var {String} + */ + this.prototype.folder = null; + } + + /** + * @param {ComposerService} composerService + * @param {String} versionSpecification + * @param {String} folder + */ + constructor(composerService, versionSpecification, folder) { + this.composerService = composerService; + this.versionSpecification = versionSpecification; + this.folder = folder; + } + + /** + * @return {Promise} + */ + install() { + this.removeExistingFolderIfPresent(); + + mkdirp(this.getCoreSourcePath()); + + return this.composerService.run([ + 'create-project', + this.COMPOSER_PACKAGE_NAME, + this.composerService.phpInvoker.normalizePlatformAndRuntimePath(this.getCoreSourcePath()), + this.versionSpecification, + // https://github.com/php-integrator/atom-base/issues/303 - Unfortunately the dist involves using a ZIP on + // Windows, which in turn causes temporary files to be created that exceed the maximum path limit. Hence + // source installation is preferred. + // '--prefer-dist', + '--prefer-source', + '--no-interaction', + '--no-dev', + '--no-progress' + ], this.folder); + } + + /** + * @return {Boolean} + */ + removeExistingFolderIfPresent() { + if (fs.existsSync(this.getCoreSourcePath())) { + return rimraf.sync(this.getCoreSourcePath()); + } + } + + /** + * @return {Boolean} + */ + isInstalled() { + return fs.existsSync(this.getComposerLockFilePath()); + } + + /** + * @return {String} + */ + getComposerLockFilePath() { + return path.join(this.getCoreSourcePath(), 'composer.lock'); + } + + /** + * @return {String} + */ + getCoreSourcePath() { + if (this.folder === null) { + throw new Error('No folder configured for core installation'); + + } else if (this.versionSpecification === null) { + throw new Error('No folder configured for core installation'); + } + + const coreSourcePath = path.join(this.folder, this.versionSpecification); + + if ((coreSourcePath == null) || (coreSourcePath.length === 0)) { + throw new Error('Failed producing a usable core source folder path'); + } + + if (coreSourcePath === '/') { + // Can never be too careful with dynamic path generation (and recursive deletes). + throw new Error('Nope, I\'m not going to use your filesystem root'); + } + + return coreSourcePath; + } + }; + CoreManager.initClass(); + return CoreManager; +})()); diff --git a/lib/DatatipProvider.coffee b/lib/DatatipProvider.coffee deleted file mode 100644 index f05506c0..00000000 --- a/lib/DatatipProvider.coffee +++ /dev/null @@ -1,84 +0,0 @@ -{Disposable, CompositeDisposable} = require 'atom' - -SymbolHelpers = require './SymbolHelpers' - -module.exports = - -##* -# Provides datatips (tooltips). -## -class DatatipProvider - ###* - * The service (that can be used to query the source code and contains utility methods). - * - * @var {Object|null} - ### - service: null - - ###* - * @var {Array} - ### - grammarScopes: ['text.html.php'] - - ###* - * @var {Number} - ### - priority: 1 - - ###* - * @var {String} - ### - providerName: 'php-integrator' - - ###* - * Initializes this provider. - * - * @param {mixed} service - ### - activate: (@service) -> - - ###* - * Deactives the provider. - ### - deactivate: () -> - - ###* - * @param {TextEditor} editor - * @param {Point} bufferPosition - * - * @return {Promise|null} - ### - datatip: (editor, bufferPosition, heldKeys) -> - if not @service.getCurrentProjectSettings() - return new Promise (resolve, reject) => - reject() - - scopeChain = editor.scopeDescriptorForBufferPosition(bufferPosition).getScopeChain() - - if scopeChain.length == 0 - return new Promise (resolve, reject) => - reject() - - # Skip whitespace and other noise - if scopeChain == '.text.html.php .meta.embedded.block.php .source.php' - return new Promise (resolve, reject) => - reject() - - successHandler = (tooltip) => - return null if not tooltip? - - return { - markedStrings : [{ - type : 'markdown' - value : tooltip.contents - }] - - # FIXME: core doesn't generate ranges yet, otherwise we could use tooltip.range - range : SymbolHelpers.getRangeForSymbolAtPosition(editor, bufferPosition) - pinnable : true - } - - failureHandler = () -> - return null - - return @service.tooltipAt(editor, bufferPosition).then(successHandler, failureHandler) diff --git a/lib/DatatipProvider.js b/lib/DatatipProvider.js new file mode 100644 index 00000000..d4d3da5f --- /dev/null +++ b/lib/DatatipProvider.js @@ -0,0 +1,108 @@ +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * DS206: Consider reworking classes to avoid initClass + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let DatatipProvider; +const {Disposable, CompositeDisposable} = require('atom'); + +const SymbolHelpers = require('./SymbolHelpers'); + +module.exports = + +//#* +// Provides datatips (tooltips). +//# +(DatatipProvider = (function() { + DatatipProvider = class DatatipProvider { + static initClass() { + /** + * The service (that can be used to query the source code and contains utility methods). + * + * @var {Object|null} + */ + this.prototype.service = null; + + /** + * @var {Array} + */ + this.prototype.grammarScopes = ['text.html.php']; + + /** + * @var {Number} + */ + this.prototype.priority = 1; + + /** + * @var {String} + */ + this.prototype.providerName = 'php-integrator'; + } + + /** + * Initializes this provider. + * + * @param {mixed} service + */ + activate(service) { + this.service = service; + } + + /** + * Deactives the provider. + */ + deactivate() {} + + /** + * @param {TextEditor} editor + * @param {Point} bufferPosition + * + * @return {Promise|null} + */ + datatip(editor, bufferPosition, heldKeys) { + if (!this.service.getCurrentProjectSettings()) { + return new Promise((resolve, reject) => { + return reject(); + }); + } + + const scopeChain = editor.scopeDescriptorForBufferPosition(bufferPosition).getScopeChain(); + + if (scopeChain.length === 0) { + return new Promise((resolve, reject) => { + return reject(); + }); + } + + // Skip whitespace and other noise + if (scopeChain === '.text.html.php .meta.embedded.block.php .source.php') { + return new Promise((resolve, reject) => { + return reject(); + }); + } + + const successHandler = tooltip => { + if ((tooltip == null)) { return null; } + + return { + markedStrings : [{ + type : 'markdown', + value : tooltip.contents + }], + + // FIXME: core doesn't generate ranges yet, otherwise we could use tooltip.range + range : SymbolHelpers.getRangeForSymbolAtPosition(editor, bufferPosition), + pinnable : true + }; + }; + + const failureHandler = () => null; + + return this.service.tooltipAt(editor, bufferPosition).then(successHandler, failureHandler); + } + }; + DatatipProvider.initClass(); + return DatatipProvider; +})()); diff --git a/lib/GotoDefinitionProvider.coffee b/lib/GotoDefinitionProvider.coffee deleted file mode 100644 index 598e8990..00000000 --- a/lib/GotoDefinitionProvider.coffee +++ /dev/null @@ -1,64 +0,0 @@ -SymbolHelpers = require './SymbolHelpers' - -module.exports = - -##* -# Handles goto definition (code navigation). -## -class GotoDefinitionProvider - ###* - * @var {Object} - ### - service: null - - ###* - * @var {CancellablePromise} - ### - pendingRequestPromise: null - - ###* - * @var {PhpInvoker} - ### - phpInvoker: null - - ###* - * @param {Object} phpInvoker - ### - constructor: (@phpInvoker) -> - - ###* - * @param {Object} service - ### - activate: (service) -> - @service = service - - ###* - * @param {TextEditor} editor - * @param {Point} bufferPosition - ### - getSuggestion: (editor, bufferPosition) -> - if @pendingRequestPromise? - @pendingRequestPromise.cancel() - @pendingRequestPromise = null - - return null if not @service? - - successHandler = (result) => - return null if not result? - - return { - range : SymbolHelpers.getRangeForSymbolAtPosition(editor, bufferPosition) - - callback : () => - atom.workspace.open(@phpInvoker.denormalizePlatformAndRuntimePath(result.uri), { - initialLine : (result.line - 1), - searchAllPanes: true - }) - } - - failureHandler = () => - return null - - @pendingRequestPromise = @service.gotoDefinitionAt(editor, bufferPosition) - - return @pendingRequestPromise.then(successHandler, failureHandler) diff --git a/lib/GotoDefinitionProvider.js b/lib/GotoDefinitionProvider.js new file mode 100644 index 00000000..ae028121 --- /dev/null +++ b/lib/GotoDefinitionProvider.js @@ -0,0 +1,87 @@ +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * DS206: Consider reworking classes to avoid initClass + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let GotoDefinitionProvider; +const SymbolHelpers = require('./SymbolHelpers'); + +module.exports = + +//#* +// Handles goto definition (code navigation). +//# +(GotoDefinitionProvider = (function() { + GotoDefinitionProvider = class GotoDefinitionProvider { + static initClass() { + /** + * @var {Object} + */ + this.prototype.service = null; + + /** + * @var {CancellablePromise} + */ + this.prototype.pendingRequestPromise = null; + + /** + * @var {PhpInvoker} + */ + this.prototype.phpInvoker = null; + } + + /** + * @param {Object} phpInvoker + */ + constructor(phpInvoker) { + this.phpInvoker = phpInvoker; + } + + /** + * @param {Object} service + */ + activate(service) { + return this.service = service; + } + + /** + * @param {TextEditor} editor + * @param {Point} bufferPosition + */ + getSuggestion(editor, bufferPosition) { + if (this.pendingRequestPromise != null) { + this.pendingRequestPromise.cancel(); + this.pendingRequestPromise = null; + } + + if ((this.service == null)) { return null; } + + const successHandler = result => { + if ((result == null)) { return null; } + + return { + range : SymbolHelpers.getRangeForSymbolAtPosition(editor, bufferPosition), + + callback : () => { + return atom.workspace.open(this.phpInvoker.denormalizePlatformAndRuntimePath(result.uri), { + initialLine : (result.line - 1), + searchAllPanes: true + }); + } + }; + }; + + const failureHandler = () => { + return null; + }; + + this.pendingRequestPromise = this.service.gotoDefinitionAt(editor, bufferPosition); + + return this.pendingRequestPromise.then(successHandler, failureHandler); + } + }; + GotoDefinitionProvider.initClass(); + return GotoDefinitionProvider; +})()); diff --git a/lib/IndexingMediator.coffee b/lib/IndexingMediator.coffee deleted file mode 100644 index 3ec51f85..00000000 --- a/lib/IndexingMediator.coffee +++ /dev/null @@ -1,147 +0,0 @@ -Popover = require './Widgets/Popover' - -CancellablePromise = require './CancellablePromise' - -module.exports = - -##* -# A mediator that mediates between classes that need to do indexing and keep updated about the results. -## -class IndexingMediator - ###* - * The proxy to use to contact the PHP side. - ### - proxy: null - - ###* - * The emitter to use to emit indexing events. - ### - indexingEventEmitter: null - - ###* - * Constructor. - * - * @param {CachingProxy} proxy - * @param {Emitter} indexingEventEmitter - ### - constructor: (@proxy, @indexingEventEmitter) -> - - ###* - * Refreshes the specified file or folder. This method is asynchronous and will return immediately. - * - * @param {String|Array} path The full path to the file or folder to refresh. Alternatively, - * this can be a list of items to index at the same time. - * @param {String|null} source The source code of the file to index. May be null if a directory is - * passed instead. - * @param {Array} excludedPaths A list of paths to exclude from indexing. - * @param {Array} fileExtensionsToIndex A list of file extensions (without leading dot) to index. - * - * @return {Promise} - ### - reindex: (path, source, excludedPaths, fileExtensionsToIndex) -> - reindexCancellablePromise = null - - cancelHandler = () => - return if not reindexCancellablePromise? - - reindexCancellablePromise.cancel() - - executor = (resolve, reject) => - @indexingEventEmitter.emit('php-ide-serenata:indexing-started', { - path : path - }) - - successHandler = (output) => - @indexingEventEmitter.emit('php-ide-serenata:indexing-finished', { - output : output - path : path - source : source - }) - - resolve(output) - - failureHandler = (error) => - @indexingEventEmitter.emit('php-ide-serenata:indexing-failed', { - error : error - path : path - source : source - }) - - reject(error) - - progressStreamCallback = (progress) => - progress = parseFloat(progress) - - if not isNaN(progress) - @indexingEventEmitter.emit('php-ide-serenata:indexing-progress', { - path : path - percentage : progress - }) - - reindexCancellablePromise = @proxy.reindex( - path, - source, - progressStreamCallback, - excludedPaths, - fileExtensionsToIndex - ) - - return reindexCancellablePromise.then(successHandler, failureHandler) - - return new CancellablePromise(executor, cancelHandler) - - ###* - * Initializes the project. - * - * @return {Promise} - ### - initialize: () -> - return @proxy.initialize() - - ###* - * Vacuums the project. - * - * @return {Promise} - ### - vacuum: () -> - return @proxy.vacuum() - - ###* - * Attaches a callback to indexing started event. The returned disposable can be used to detach your event handler. - * - * @param {Callback} callback A callback that takes one parameter which contains a 'path' property. - * - * @return {Disposable} - ### - onDidStartIndexing: (callback) -> - @indexingEventEmitter.on('php-ide-serenata:indexing-started', callback) - - ###* - * Attaches a callback to indexing progress event. The returned disposable can be used to detach your event handler. - * - * @param {Callback} callback A callback that takes one parameter which contains a 'path' and a 'percentage' property. - * - * @return {Disposable} - ### - onDidIndexingProgress: (callback) -> - @indexingEventEmitter.on('php-ide-serenata:indexing-progress', callback) - - ###* - * Attaches a callback to indexing finished event. The returned disposable can be used to detach your event handler. - * - * @param {Callback} callback A callback that takes one parameter which contains an 'output' and a 'path' property. - * - * @return {Disposable} - ### - onDidFinishIndexing: (callback) -> - @indexingEventEmitter.on('php-ide-serenata:indexing-finished', callback) - - ###* - * Attaches a callback to indexing failed event. The returned disposable can be used to detach your event handler. - * - * @param {Callback} callback A callback that takes one parameter which contains an 'error' and a 'path' property. - * - * @return {Disposable} - ### - onDidFailIndexing: (callback) -> - @indexingEventEmitter.on('php-ide-serenata:indexing-failed', callback) diff --git a/lib/IndexingMediator.js b/lib/IndexingMediator.js new file mode 100644 index 00000000..ef021916 --- /dev/null +++ b/lib/IndexingMediator.js @@ -0,0 +1,178 @@ +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * DS206: Consider reworking classes to avoid initClass + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let IndexingMediator; +const Popover = require('./Widgets/Popover'); + +const CancellablePromise = require('./CancellablePromise'); + +module.exports = + +//#* +// A mediator that mediates between classes that need to do indexing and keep updated about the results. +//# +(IndexingMediator = (function() { + IndexingMediator = class IndexingMediator { + static initClass() { + /** + * The proxy to use to contact the PHP side. + */ + this.prototype.proxy = null; + + /** + * The emitter to use to emit indexing events. + */ + this.prototype.indexingEventEmitter = null; + } + + /** + * Constructor. + * + * @param {CachingProxy} proxy + * @param {Emitter} indexingEventEmitter + */ + constructor(proxy, indexingEventEmitter) { + this.proxy = proxy; + this.indexingEventEmitter = indexingEventEmitter; + } + + /** + * Refreshes the specified file or folder. This method is asynchronous and will return immediately. + * + * @param {String|Array} path The full path to the file or folder to refresh. Alternatively, + * this can be a list of items to index at the same time. + * @param {String|null} source The source code of the file to index. May be null if a directory is + * passed instead. + * @param {Array} excludedPaths A list of paths to exclude from indexing. + * @param {Array} fileExtensionsToIndex A list of file extensions (without leading dot) to index. + * + * @return {Promise} + */ + reindex(path, source, excludedPaths, fileExtensionsToIndex) { + let reindexCancellablePromise = null; + + const cancelHandler = () => { + if ((reindexCancellablePromise == null)) { return; } + + return reindexCancellablePromise.cancel(); + }; + + const executor = (resolve, reject) => { + this.indexingEventEmitter.emit('php-ide-serenata:indexing-started', { + path + }); + + const successHandler = output => { + this.indexingEventEmitter.emit('php-ide-serenata:indexing-finished', { + output, + path, + source + }); + + return resolve(output); + }; + + const failureHandler = error => { + this.indexingEventEmitter.emit('php-ide-serenata:indexing-failed', { + error, + path, + source + }); + + return reject(error); + }; + + const progressStreamCallback = progress => { + progress = parseFloat(progress); + + if (!isNaN(progress)) { + return this.indexingEventEmitter.emit('php-ide-serenata:indexing-progress', { + path, + percentage : progress + }); + } + }; + + reindexCancellablePromise = this.proxy.reindex( + path, + source, + progressStreamCallback, + excludedPaths, + fileExtensionsToIndex + ); + + return reindexCancellablePromise.then(successHandler, failureHandler); + }; + + return new CancellablePromise(executor, cancelHandler); + } + + /** + * Initializes the project. + * + * @return {Promise} + */ + initialize() { + return this.proxy.initialize(); + } + + /** + * Vacuums the project. + * + * @return {Promise} + */ + vacuum() { + return this.proxy.vacuum(); + } + + /** + * Attaches a callback to indexing started event. The returned disposable can be used to detach your event handler. + * + * @param {Callback} callback A callback that takes one parameter which contains a 'path' property. + * + * @return {Disposable} + */ + onDidStartIndexing(callback) { + return this.indexingEventEmitter.on('php-ide-serenata:indexing-started', callback); + } + + /** + * Attaches a callback to indexing progress event. The returned disposable can be used to detach your event handler. + * + * @param {Callback} callback A callback that takes one parameter which contains a 'path' and a 'percentage' property. + * + * @return {Disposable} + */ + onDidIndexingProgress(callback) { + return this.indexingEventEmitter.on('php-ide-serenata:indexing-progress', callback); + } + + /** + * Attaches a callback to indexing finished event. The returned disposable can be used to detach your event handler. + * + * @param {Callback} callback A callback that takes one parameter which contains an 'output' and a 'path' property. + * + * @return {Disposable} + */ + onDidFinishIndexing(callback) { + return this.indexingEventEmitter.on('php-ide-serenata:indexing-finished', callback); + } + + /** + * Attaches a callback to indexing failed event. The returned disposable can be used to detach your event handler. + * + * @param {Callback} callback A callback that takes one parameter which contains an 'error' and a 'path' property. + * + * @return {Disposable} + */ + onDidFailIndexing(callback) { + return this.indexingEventEmitter.on('php-ide-serenata:indexing-failed', callback); + } + }; + IndexingMediator.initClass(); + return IndexingMediator; +})()); diff --git a/lib/LinterProvider.coffee b/lib/LinterProvider.coffee deleted file mode 100644 index d4afd101..00000000 --- a/lib/LinterProvider.coffee +++ /dev/null @@ -1,218 +0,0 @@ -{CompositeDisposable} = require 'atom' - -module.exports = - -##* -# Provider of linter messages to the (indie) linter service. -## -class LinterProvider - ###* - * @var {String} - ### - scope: 'file' - - ###* - * @var {Boolean} - ### - lintsOnChange: true - - ###* - * @var {Array} - ### - grammarScopes: ['source.php'] - - ###* - * @var {Object} - ### - service: null - - ###* - * @var {Object} - ### - config: null - - ###* - * @var {CompositeDisposable} - ### - disposables: null - - ###* - * @var {Object} - ### - indieLinter: null - - ###* - * @var {CancellablePromise} - ### - pendingRequestPromise: null - - ###* - * Constructor. - * - * @param {Config} config - ### - constructor: (@config) -> - - ###* - * @param {Object} indieLinter - ### - setIndieLinter: (@indieLinter) -> - @messages = [] - - ###* - * Initializes this provider. - * - * @param {Object} service - ### - activate: (@service) -> - @disposables = new CompositeDisposable() - - @attachListeners(@service) - - ###* - * Deactives the provider. - ### - deactivate: () -> - @disposables.dispose() - - ###* - * @param {Object} service - ### - attachListeners: (service) -> - @disposables.add service.onDidFinishIndexing (response) => - editor = @findTextEditorByPath(response.path) - - return if not editor? - return if not @indieLinter? - - @lint(editor, response.source) - - @disposables.add service.onDidFailIndexing (response) => - editor = @findTextEditorByPath(response.path) - - return if not editor? - return if not @indieLinter? - - @lint(editor, response.source) - - ###* - * @param {TextEditor} editor - * @param {String} source - ### - lint: (editor, source) -> - if @pendingRequestPromise? - @pendingRequestPromise.cancel() - @pendingRequestPromise = null - - successHandler = (response) => - return @processSuccess(editor, response, source) - - failureHandler = (response) => - return @processFailure(editor) - - @pendingRequestPromise = @invokeLint(editor.getPath(), source) - - return @pendingRequestPromise.then(successHandler, failureHandler) - - ###* - * @param {String} path - * @param {String} source - * - * @return {CancellablePromise} - ### - invokeLint: (path, source) -> - options = { - noUnknownClasses : not @config.get('linting.showUnknownClasses') - noUnknownMembers : not @config.get('linting.showUnknownMembers') - noUnknownGlobalFunctions : not @config.get('linting.showUnknownGlobalFunctions') - noUnknownGlobalConstants : not @config.get('linting.showUnknownGlobalConstants') - noUnusedUseStatements : not @config.get('linting.showUnusedUseStatements') - noDocblockCorrectness : not @config.get('linting.validateDocblockCorrectness') - noMissingDocumentation : not @config.get('linting.showMissingDocs') - } - - return @service.lint(path, source, options) - - ###* - * @param {TextEditor} editor - * @param {Object} response - * @param {String} source - * - * @return {Array} - ### - processSuccess: (editor, response, source) -> - messages = [] - - for item in response.errors - messages.push @createLinterErrorMessageForOutputItem(editor, item, source) - - for item in response.warnings - messages.push @createLinterWarningMessageForOutputItem(editor, item, source) - - @indieLinter.setMessages(editor.getPath(), messages) - - ###* - * @param {TextEditor} editor - * - * @return {Array} - ### - processFailure: (editor) -> - @indieLinter.setMessages(editor.getPath(), []) - - ###* - * @param {TextEditor} editor - * @param {Object} item - * @param {String} source - * - * @return {Object} - ### - createLinterErrorMessageForOutputItem: (editor, item, source) -> - return @createLinterMessageForOutputItem(editor, item, source, 'error') - - ###* - * @param {TextEditor} editor - * @param {Object} item - * @param {String} source - * - * @return {Object} - ### - createLinterWarningMessageForOutputItem: (editor, item, source) -> - return @createLinterMessageForOutputItem(editor, item, source, 'warning') - - ###* - * @param {TextEditor} editor - * @param {Object} item - * @param {String} source - * @param {String} severity - * - * @return {Object} - ### - createLinterMessageForOutputItem: (editor, item, source, severity) -> - startCharacterOffset = @service.getCharacterOffsetFromByteOffset(item.start, source) - endCharacterOffset = @service.getCharacterOffsetFromByteOffset(item.end, source) - - startPoint = editor.getBuffer().positionForCharacterIndex(startCharacterOffset) - endPoint = editor.getBuffer().positionForCharacterIndex(endCharacterOffset) - - return { - excerpt : item.message - severity : severity - - location: - file : editor.getPath() - position : [startPoint, endPoint] - } - - ###* - * Retrieves the text editor that is managing the file with the specified path. - * - * @param {String} path - * - * @return {TextEditor|null} - ### - findTextEditorByPath: (path) -> - for textEditor in atom.workspace.getTextEditors() - if textEditor and textEditor.getPath() == path - return textEditor - - return null diff --git a/lib/LinterProvider.js b/lib/LinterProvider.js new file mode 100644 index 00000000..f08f1f01 --- /dev/null +++ b/lib/LinterProvider.js @@ -0,0 +1,262 @@ +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS206: Consider reworking classes to avoid initClass + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let LinterProvider; +const {CompositeDisposable} = require('atom'); + +module.exports = + +//#* +// Provider of linter messages to the (indie) linter service. +//# +(LinterProvider = (function() { + LinterProvider = class LinterProvider { + static initClass() { + /** + * @var {String} + */ + this.prototype.scope = 'file'; + + /** + * @var {Boolean} + */ + this.prototype.lintsOnChange = true; + + /** + * @var {Array} + */ + this.prototype.grammarScopes = ['source.php']; + + /** + * @var {Object} + */ + this.prototype.service = null; + + /** + * @var {Object} + */ + this.prototype.config = null; + + /** + * @var {CompositeDisposable} + */ + this.prototype.disposables = null; + + /** + * @var {Object} + */ + this.prototype.indieLinter = null; + + /** + * @var {CancellablePromise} + */ + this.prototype.pendingRequestPromise = null; + } + + /** + * Constructor. + * + * @param {Config} config + */ + constructor(config) { + this.config = config; + } + + /** + * @param {Object} indieLinter + */ + setIndieLinter(indieLinter) { + this.indieLinter = indieLinter; + return this.messages = []; + } + + /** + * Initializes this provider. + * + * @param {Object} service + */ + activate(service) { + this.service = service; + this.disposables = new CompositeDisposable(); + + return this.attachListeners(this.service); + } + + /** + * Deactives the provider. + */ + deactivate() { + return this.disposables.dispose(); + } + + /** + * @param {Object} service + */ + attachListeners(service) { + this.disposables.add(service.onDidFinishIndexing(response => { + const editor = this.findTextEditorByPath(response.path); + + if ((editor == null)) { return; } + if ((this.indieLinter == null)) { return; } + + return this.lint(editor, response.source); + }) + ); + + return this.disposables.add(service.onDidFailIndexing(response => { + const editor = this.findTextEditorByPath(response.path); + + if ((editor == null)) { return; } + if ((this.indieLinter == null)) { return; } + + return this.lint(editor, response.source); + }) + ); + } + + /** + * @param {TextEditor} editor + * @param {String} source + */ + lint(editor, source) { + if (this.pendingRequestPromise != null) { + this.pendingRequestPromise.cancel(); + this.pendingRequestPromise = null; + } + + const successHandler = response => { + return this.processSuccess(editor, response, source); + }; + + const failureHandler = response => { + return this.processFailure(editor); + }; + + this.pendingRequestPromise = this.invokeLint(editor.getPath(), source); + + return this.pendingRequestPromise.then(successHandler, failureHandler); + } + + /** + * @param {String} path + * @param {String} source + * + * @return {CancellablePromise} + */ + invokeLint(path, source) { + const options = { + noUnknownClasses : !this.config.get('linting.showUnknownClasses'), + noUnknownMembers : !this.config.get('linting.showUnknownMembers'), + noUnknownGlobalFunctions : !this.config.get('linting.showUnknownGlobalFunctions'), + noUnknownGlobalConstants : !this.config.get('linting.showUnknownGlobalConstants'), + noUnusedUseStatements : !this.config.get('linting.showUnusedUseStatements'), + noDocblockCorrectness : !this.config.get('linting.validateDocblockCorrectness'), + noMissingDocumentation : !this.config.get('linting.showMissingDocs') + }; + + return this.service.lint(path, source, options); + } + + /** + * @param {TextEditor} editor + * @param {Object} response + * @param {String} source + * + * @return {Array} + */ + processSuccess(editor, response, source) { + const messages = []; + + for (var item of Array.from(response.errors)) { + messages.push(this.createLinterErrorMessageForOutputItem(editor, item, source)); + } + + for (item of Array.from(response.warnings)) { + messages.push(this.createLinterWarningMessageForOutputItem(editor, item, source)); + } + + return this.indieLinter.setMessages(editor.getPath(), messages); + } + + /** + * @param {TextEditor} editor + * + * @return {Array} + */ + processFailure(editor) { + return this.indieLinter.setMessages(editor.getPath(), []); + } + + /** + * @param {TextEditor} editor + * @param {Object} item + * @param {String} source + * + * @return {Object} + */ + createLinterErrorMessageForOutputItem(editor, item, source) { + return this.createLinterMessageForOutputItem(editor, item, source, 'error'); + } + + /** + * @param {TextEditor} editor + * @param {Object} item + * @param {String} source + * + * @return {Object} + */ + createLinterWarningMessageForOutputItem(editor, item, source) { + return this.createLinterMessageForOutputItem(editor, item, source, 'warning'); + } + + /** + * @param {TextEditor} editor + * @param {Object} item + * @param {String} source + * @param {String} severity + * + * @return {Object} + */ + createLinterMessageForOutputItem(editor, item, source, severity) { + const startCharacterOffset = this.service.getCharacterOffsetFromByteOffset(item.start, source); + const endCharacterOffset = this.service.getCharacterOffsetFromByteOffset(item.end, source); + + const startPoint = editor.getBuffer().positionForCharacterIndex(startCharacterOffset); + const endPoint = editor.getBuffer().positionForCharacterIndex(endCharacterOffset); + + return { + excerpt : item.message, + severity, + + location: { + file : editor.getPath(), + position : [startPoint, endPoint] + } + }; + } + + /** + * Retrieves the text editor that is managing the file with the specified path. + * + * @param {String} path + * + * @return {TextEditor|null} + */ + findTextEditorByPath(path) { + for (let textEditor of Array.from(atom.workspace.getTextEditors())) { + if (textEditor && (textEditor.getPath() === path)) { + return textEditor; + } + } + + return null; + } + }; + LinterProvider.initClass(); + return LinterProvider; +})()); diff --git a/lib/Main.coffee b/lib/Main.coffee deleted file mode 100644 index 4d8ff1d3..00000000 --- a/lib/Main.coffee +++ /dev/null @@ -1,1448 +0,0 @@ -{Disposable, CompositeDisposable} = require 'atom'; - -{Emitter} = require 'event-kit'; - -packageDeps = require('atom-package-deps') - -fs = require 'fs' -process = require 'process' - -Proxy = require './Proxy' -Service = require './Service' -AtomConfig = require './AtomConfig' -PhpInvoker = require './PhpInvoker' -CoreManager = require './CoreManager'; -ConfigTester = require './ConfigTester' -ProjectManager = require './ProjectManager' -LinterProvider = require './LinterProvider' -ComposerService = require './ComposerService'; -DatatipProvider = require './DatatipProvider' -IndexingMediator = require './IndexingMediator' -UseStatementHelper = require './UseStatementHelper'; -SignatureHelpProvider = require './SignatureHelpProvider' -GotoDefinitionProvider = require './GotoDefinitionProvider' -AutocompletionProvider = require './AutocompletionProvider' - -MethodAnnotationProvider = require './Annotations/MethodAnnotationProvider' -PropertyAnnotationProvider = require './Annotations/PropertyAnnotationProvider' - -DocblockProvider = require './Refactoring/DocblockProvider' -GetterSetterProvider = require './Refactoring/GetterSetterProvider' -ExtractMethodProvider = require './Refactoring/ExtractMethodProvider' -OverrideMethodProvider = require './Refactoring/OverrideMethodProvider' -IntroducePropertyProvider = require './Refactoring/IntroducePropertyProvider' -StubAbstractMethodProvider = require './Refactoring/StubAbstractMethodProvider' -StubInterfaceMethodProvider = require './Refactoring/StubInterfaceMethodProvider' -ConstructorGenerationProvider = require './Refactoring/ConstructorGenerationProvider' - -Builder = require './Refactoring/ExtractMethodProvider/Builder' -TypeHelper = require './Refactoring/Utility/TypeHelper' -DocblockBuilder = require './Refactoring/Utility/DocblockBuilder' -FunctionBuilder = require './Refactoring/Utility/FunctionBuilder' -ParameterParser = require './Refactoring/ExtractMethodProvider/ParameterParser' - -module.exports = - ###* - * Configuration settings. - ### - config: - core: - type: 'object' - order: 1 - properties: - phpExecutionType: - title : 'PHP execution type' - description : "How to start PHP, which is needed to start the core in turn. \n \n - - 'Use PHP on the host' uses a PHP binary installed on your local machine. 'Use PHP - container via Docker' requires Docker and uses a PHP container to start the server - with. Using PolicyKit allows Linux users that are not part of the Docker group to - enter their password via an authentication dialog to temporarily escalate privileges - so the Docker daemon can be invoked once to start the server. \n \n - - You can use the php-ide-serenata:test-configuration command to test your setup. - \n \n - - Requires a restart after changing. \n \n" - type : 'string' - default : 'host' - order : 1 - enum : [ - { - value : 'host' - description : 'Use PHP on the host' - }, - - { - value : 'docker' - description : 'Use a PHP container via Docker (experimental)' - }, - - { - value : 'docker-polkit' - description : 'Use a PHP container via Docker, using PolicyKit for privilege escalation ' + - ' (experimental, Linux only)' - } - ] - - phpCommand: - title : 'PHP command' - description : 'The path to your PHP binary (e.g. /usr/bin/php, php, ...). Only applies if you\'ve - selected "Use PHP on the host" above. \n \n - - Requires a restart after changing.' - type : 'string' - default : 'php' - order : 2 - - memoryLimit: - title : 'Memory limit (in MB)' - description : 'The memory limit to set for the PHP process. The PHP process uses the available - memory for in-memory caching as well, so it should not be too low. On the other hand, - it shouldn\'t be growing very large, so setting it to -1 is probably a bad idea as - an infinite loop bug might take down your system. The default should suit most - projects, from small to large. \n \n - Requires a restart after changing.' - type : 'integer' - default : 2048 - order : 3 - - additionalDockerVolumes: - title : 'Additional Docker volumes' - description : 'Additional paths to mount as Docker volumes. Only applies when using Docker to run - the core. Separate these using comma\'s, where each item follows the format - "src:dest" as the Docker -v flag uses. \n \n - Requires a restart after changing.' - type : 'array' - default : [] - order : 4 - items : - type : 'string' - - general: - type: 'object' - order: 2 - properties: - indexContinuously: - title : 'Index continuously' - description : 'If enabled, indexing will happen continuously and automatically whenever the editor - is modified. If disabled, indexing will only happen on save. This also influences - linting, which happens automatically after indexing completes. In other words, if - you would like linting to happen on save, you can disable this option.' - type : 'boolean' - default : true - order : 1 - - additionalIndexingDelay: - title : 'Additional delay before reindexing (in ms)' - description : 'Only applies when indexing continously, which happens after a fixed time (about 300 - ms at the time of writing and managed by Atom). If this is too fast for you, you can - introduce an additional delay here. Fewer indexes means less load as tasks such as - linting are invoked less often. However, it also means that it will take longer for - changes to code to be reflected in, for example, autocompletion.' - type : 'integer' - default : 500 - order : 2 - - datatips: - type: 'object' - order: 3 - properties: - enable: - title : 'Enable' - description : 'When enabled, documentation for various structural elements can be displayed in a - datatip (tooltip).' - type : 'boolean' - default : true - order : 1 - - signatureHelp: - type: 'object' - order: 4 - properties: - enable: - title : 'Enable' - description : 'When enabled, signature help (call tips) will be displayed when the keyboard cursor - is inside a function, method or constructor call.' - type : 'boolean' - default : true - order : 1 - - gotoDefinition: - type: 'object' - order: 5 - properties: - enable: - title : 'Enable' - description : 'When enabled, code navigation will be activated via the hyperclick package.' - type : 'boolean' - default : true - order : 1 - - autocompletion: - type: 'object' - order: 6 - properties: - enable: - title : 'Enable' - description : 'When enabled, autocompletion will be activated via the autocomplete-plus package.' - type : 'boolean' - default : true - order : 1 - - annotations: - type: 'object' - order: 7 - properties: - enable: - title : 'Enable' - description : 'When enabled, annotations will be shown in the gutter with more information - regarding member overrides and interface implementations.' - type : 'boolean' - default : true - order : 1 - - refactoring: - type: 'object' - order: 8 - properties: - enable: - title : 'Enable' - description : 'When enabled, refactoring actions will be available via the intentions package.' - type : 'boolean' - default : true - order : 1 - - linting: - type: 'object' - order: 9 - properties: - enable: - title : 'Enable' - description : 'When enabled, linting will show problems and warnings picked up in your code.' - type : 'boolean' - default : true - order : 1 - - showUnknownClasses: - title : 'Show unknown classes' - description : 'Highlights class names that could not be found. This will also work for docblocks.' - type : 'boolean' - default : true - order : 2 - - showUnknownGlobalFunctions: - title : 'Show unknown (global) functions' - description : 'Highlights (global) functions that could not be found.' - type : 'boolean' - default : true - order : 3 - - showUnknownGlobalConstants: - title : 'Show unknown (global) constants' - description : 'Highlights (global) constants that could not be found.' - type : 'boolean' - default : true - order : 4 - - showUnusedUseStatements: - title : 'Show unused use statements' - description : 'Highlights use statements that don\'t seem to be used anywhere.' - type : 'boolean' - default : true - order : 5 - - showMissingDocs: - title : 'Show missing documentation' - description : 'Warns about structural elements that are missing documentation.' - type : 'boolean' - default : true - order : 6 - - validateDocblockCorrectness: - title : 'Validate docblock correctness' - description : ''' - Analyzes the correctness of docblocks of various structural elements and will show various - problems such as undocumented parameters, mismatched parameter and deprecated tags. - ''' - type : 'boolean' - default : true - order : 7 - - showUnknownMembers: - title : 'Show unknown members (experimental)' - description : ''' - Highlights use of unknown members. Note that this can be a large strain on performance and is - experimental (expect false positives, especially inside conditionals). - ''' - type : 'boolean' - default : false - order : 8 - - ###* - * The version of the core to download (version specification string). - * - * @var {String} - ### - coreVersionSpecification: "4.0.1" - - ###* - * The name of the package. - * - * @var {String} - ### - packageName: 'php-ide-serenata' - - ###* - * The configuration object. - * - * @var {Object} - ### - configuration: null - - ###* - * @var {Object} - ### - PhpInvoker: null - - ###* - * The proxy object. - * - * @var {Object} - ### - proxy: null - - ###* - * The exposed service. - * - * @var {Object} - ### - service: null - - ###* - * @var {IndexingMediator} - ### - indexingMediator: null - - ###* - * A list of disposables to dispose when the package deactivates. - * - * @var {Object|null} - ### - disposables: null - - ###* - * The currently active project, if any. - * - * @var {Object|null} - ### - activeProject: null - - ###* - * @var {String|null} - ### - timerName: null - - ###* - * @var {Object|null} - ### - typeHelper: null - - ###* - * @var {Object|null} - ### - docblockBuilder: null - - ###* - * @var {Object|null} - ### - functionBuilder: null - - ###* - * @var {Object|null} - ### - parameterParser: null - - ###* - * @var {Object|null} - ### - builder: null - - ###* - * The service instance from the project-manager package. - * - * @var {Object|null} - ### - projectManagerService: null - - ###* - * @var {Object|null} - ### - editorTimeoutMap: null - - ###* - * @var {Object|null} - ### - datatipProvider: null - - ###* - * @var {Object|null} - ### - signatureHelpProvider: null - - ###* - * @var {Object|null} - ### - gotoDefinitionProvider: null - - ###* - * @var {Array|null} - ### - annotationProviders: null - - ###* - * @var {Array|null} - ### - refactoringProviders: null - - ###* - * @var {Object|null} - ### - linterProvider: null - - ###* - * @var {Object|null} - ### - busySignalService: null - - ###* - * Tests the user's configuration. - ### - testConfig: () -> - configTester = new ConfigTester(@getPhpInvoker()) - - atom.notifications.addInfo 'Serenata - Testing Configuration', { - dismissable: true, - detail: 'Now testing your configuration... \n \n' + - - 'If you\'ve selected Docker, this may take a while the first time - as the Docker image has to be fetched first.' - } - - callback = () => - return configTester.test().then (wasSuccessful) => - if not wasSuccessful - errorMessage = - "PHP is not configured correctly. Please visit the settings screen to correct this error. If you are - using a relative path to PHP, make sure it is in your PATH variable." - - atom.notifications.addError('Serenata - Failure', {dismissable: true, detail: errorMessage}) - - else - atom.notifications.addSuccess 'Serenata - Success', { - dismissable: true, - detail: 'Your setup is working correctly.' - } - - return @busySignalService.reportBusyWhile('Testing your configuration...', callback, { - waitingFor : 'computer', - revealTooltip : false - }); - - ###* - * Registers any commands that are available to the user. - ### - registerCommands: () -> - atom.commands.add 'atom-workspace', "php-ide-serenata:set-up-current-project": => - if not @projectManagerService? - errorMessage = ''' - The project manager service was not found. Did you perhaps forget to install the project-manager - package or another package able to provide it? - ''' - - atom.notifications.addError('Incorrect setup!', {'detail': errorMessage}) - return - - if not @activeProject? - errorMessage = ''' - No project is currently active. Please save and activate one before attempting to set it up. - You can do it via the menu Packages → Project Manager → Save Project. - ''' - - atom.notifications.addError('Incorrect setup!', {'detail': errorMessage}) - return - - project = @activeProject - - newProperties = null - - try - newProperties = @projectManager.setUpProject(project) - - if not newProperties? - throw new Error('No properties returned, this should never happen!') - - catch error - atom.notifications.addError('Error!', { - 'detail' : error.message - }) - - return - - @projectManagerService.saveProject(newProperties) - - atom.notifications.addSuccess 'Success', { - 'detail' : 'Your current project has been set up as PHP project. Indexing will now commence.' - } - - @projectManager.load(project) - - @performInitialFullIndexForCurrentProject() - - atom.commands.add 'atom-workspace', "php-ide-serenata:index-project": => - return if not @projectManager.hasActiveProject() - - @projectManager.attemptCurrentProjectIndex() - - atom.commands.add 'atom-workspace', "php-ide-serenata:force-index-project": => - return if not @projectManager.hasActiveProject() - - @performInitialFullIndexForCurrentProject() - - atom.commands.add 'atom-workspace', "php-ide-serenata:test-configuration": => - @testConfig() - - atom.commands.add 'atom-workspace', "php-ide-serenata:sort-use-statements": => - activeTextEditor = atom.workspace.getActiveTextEditor() - - return if not activeTextEditor? - - @getUseStatementHelper().sortUseStatements(activeTextEditor) - - ###* - * Performs the "initial" index for a new project by initializing it and then performing a project index. - * - * @return {Promise} - ### - performInitialFullIndexForCurrentProject: () -> - successHandler = () => - return @projectManager.attemptCurrentProjectIndex() - - failureHandler = (reason) => - console.error(reason) - - atom.notifications.addError('Error!', { - 'detail' : 'The project could not be properly initialized!' - }) - - return @projectManager.initializeCurrentProject().then(successHandler, failureHandler) - - ###* - * Registers listeners for configuration changes. - ### - registerConfigListeners: () -> - config = @getConfiguration() - - config.onDidChange 'datatips.enable', (value) => - if value - @activateDatatips() - - else - @deactivateDatatips() - - config.onDidChange 'signatureHelp.enable', (value) => - if value - @activateSignatureHelp() - - else - @deactivateSignatureHelp() - - config.onDidChange 'gotoDefintion.enable', (value) => - if value - @activateGotoDefinition() - - else - @deactivateGotoDefinition() - - config.onDidChange 'autocompletion.enable', (value) => - if value - @activateAutocompletion() - - else - @deactivateAutocompletion() - - config.onDidChange 'annotations.enable', (value) => - if value - @activateAnnotations() - - else - @deactivateAnnotations() - - config.onDidChange 'refactoring.enable', (value) => - if value - @activateRefactoring() - - else - @deactivateRefactoring() - - config.onDidChange 'linting.enable', (value) => - if value - @activateLinting() - - else - @deactivateLinting() - - ###* - * Registers status bar listeners. - ### - registerStatusBarListeners: () -> - service = @getService() - - indexBusyMessageMap = new Map() - - getBaseMessageForPath = (path) -> - if Array.isArray(path) - path = path[0] - - if path.indexOf('~') != false - path = path.replace('~', process.env.HOME) - - if fs.lstatSync(path).isDirectory() - return 'Indexing project - code assistance may be unavailable or incomplete' - - return 'Indexing ' + path - - service.onDidStartIndexing ({path}) => - if not indexBusyMessageMap.has(path) - indexBusyMessageMap.set(path, new Array()) - - indexBusyMessageMap.get(path).push(@busySignalService.reportBusy(getBaseMessageForPath(path), { - waitingFor : 'computer', - revealTooltip : true - })) - - service.onDidFinishIndexing ({path}) => - return if not indexBusyMessageMap.has(path) - - indexBusyMessageMap.get(path).forEach((busyMessage) => busyMessage.dispose()) - indexBusyMessageMap.delete(path) - - service.onDidFailIndexing ({path}) => - return if not indexBusyMessageMap.has(path) - - indexBusyMessageMap.get(path).forEach((busyMessage) => busyMessage.dispose()) - indexBusyMessageMap.delete(path) - - service.onDidIndexingProgress ({path, percentage}) => - return if not indexBusyMessageMap.has(path) - - indexBusyMessageMap.get(path).forEach (busyMessage) => - busyMessage.setTitle(getBaseMessageForPath(path) + " (" + percentage.toFixed(2) + " %)") - - ###* - * @return {Promise} - ### - installCoreIfNecessary: () -> - return new Promise (resolve, reject) => - if @getCoreManager().isInstalled() - resolve() - return - - message = - "The core isn't installed yet or is outdated. I can install the latest version for you " + - "automatically.\n \n" + - - "First time using this package? Please visit the package settings to set up PHP correctly first." - - notification = atom.notifications.addInfo('Serenata - Core Installation', { - detail : message - dismissable : true - - buttons: [ - { - text: 'Open package settings' - onDidClick: () => - atom.workspace.open('atom://config/packages/' + @packageName) - }, - - { - text: 'Test my setup' - onDidClick: () => - @testConfig() - }, - - { - text: 'Ready, install the core' - onDidClick: () => - notification.dismiss() - - callback = () => - promise = @installCore() - - promise.catch () => - reject(new Error('Core installation failed')) - - return promise.then () => - resolve() - - if @busySignalService - @busySignalService.reportBusyWhile('Installing the core...', callback, { - waitingFor : 'computer', - revealTooltip : false - }) - - else - console.warn( - 'Busy signal service not loaded yet whilst installing core, not showing ' + - 'loading spinner' - ) - }, - - { - text: 'No, go away' - onDidClick: () => - notification.dismiss() - reject() - } - ] - }) - - ###* - * @return {Promise} - ### - installCore: () -> - message = - "The core is being downloaded and installed. To do this, Composer is automatically downloaded and " + - "installed into a temporary folder.\n \n" + - - "Progress and output is sent to the developer tools console, in case you'd like to monitor it.\n \n" + - - "You will be notified once the install finishes (or fails)." - - atom.notifications.addInfo('Serenata - Installing Core', {'detail': message, dismissable: true}) - - successHandler = () -> - atom.notifications.addSuccess('Serenata - Core Installation Succeeded', dismissable: true) - - failureHandler = () -> - message = - "Installation of the core failed. This can happen for a variety of reasons, such as an outdated PHP " + - "version or missing extensions.\n \n" + - - "Logs in the developer tools will likely provide you with more information about what is wrong. You " + - "can open it via the menu View → Developer → Toggle Developer Tools.\n \n" + - - "Additionally, the README provides more information about requirements and troubleshooting." - - atom.notifications.addError('Serenata - Core Installation Failed', {detail: message, dismissable: true}) - - @getCoreManager().install().then(successHandler, failureHandler) - - ###* - * Checks if the php-integrator-navigation package is installed and notifies the user it is obsolete if it is. - ### - notifyAboutRedundantNavigationPackageIfNecessary: () -> - atom.packages.onDidActivatePackage (packageData) -> - return if packageData.name != 'php-integrator-navigation' - - message = - "It seems you still have the php-integrator-navigation package installed and activated. As of this " + - "release, it is obsolete and all its functionality is already included in the base package.\n \n" + - - "It is recommended to disable or remove it, shall I disable it for you?" - - notification = atom.notifications.addInfo('Serenata - Navigation', { - detail : message - dismissable : true - - buttons: [ - { - text: 'Yes, nuke it' - onDidClick: () -> - atom.packages.disablePackage('php-integrator-navigation'); - notification.dismiss() - }, - - { - text: 'No, don\'t touch it' - onDidClick: () -> - notification.dismiss() - } - ] - }) - - ###* - * Checks if the php-integrator-autocomplete-plus package is installed and notifies the user it is obsolete if it - * is. - ### - notifyAboutRedundantAutocompletionPackageIfNecessary: () -> - atom.packages.onDidActivatePackage (packageData) -> - return if packageData.name != 'php-integrator-autocomplete-plus' - - message = - "It seems you still have the php-integrator-autocomplete-plus package installed and activated. As of " + - "this release, it is obsolete and all its functionality is already included in the base package.\n \n" + - - "It is recommended to disable or remove it, shall I disable it for you?" - - notification = atom.notifications.addInfo('Serenata - Autocompletion', { - detail : message - dismissable : true - - buttons: [ - { - text: 'Yes, nuke it' - onDidClick: () -> - atom.packages.disablePackage('php-integrator-autocomplete-plus'); - notification.dismiss() - }, - - { - text: 'No, don\'t touch it' - onDidClick: () -> - notification.dismiss() - } - ] - }) - - ###* - * Checks if the php-integrator-annotations package is installed and notifies the user it is obsolete if it - * is. - ### - notifyAboutRedundantAnnotationsPackageIfNecessary: () -> - atom.packages.onDidActivatePackage (packageData) -> - return if packageData.name != 'php-integrator-annotations' - - message = - "It seems you still have the php-integrator-annotations package installed and activated. As of " + - "this release, it is obsolete and all its functionality is already included in the base package.\n \n" + - - "It is recommended to disable or remove it, shall I disable it for you?" - - notification = atom.notifications.addInfo('Serenata - Autocompletion', { - detail : message - dismissable : true - - buttons: [ - { - text: 'Yes, nuke it' - onDidClick: () -> - atom.packages.disablePackage('php-integrator-annotations'); - notification.dismiss() - }, - - { - text: 'No, don\'t touch it' - onDidClick: () -> - notification.dismiss() - } - ] - }) - - ###* - * Checks if the php-integrator-refactoring package is installed and notifies the user it is obsolete if it - * is. - ### - notifyAboutRedundantRefactoringPackageIfNecessary: () -> - atom.packages.onDidActivatePackage (packageData) -> - return if packageData.name != 'php-integrator-refactoring' - - message = - "It seems you still have the php-integrator-refactoring package installed and activated. As of " + - "this release, it is obsolete and all its functionality is already included in the base package.\n \n" + - - "It is recommended to disable or remove it, shall I disable it for you?" - - notification = atom.notifications.addInfo('Serenata - Autocompletion', { - detail : message - dismissable : true - - buttons: [ - { - text: 'Yes, nuke it' - onDidClick: () -> - atom.packages.disablePackage('php-integrator-refactoring'); - notification.dismiss() - }, - - { - text: 'No, don\'t touch it' - onDidClick: () -> - notification.dismiss() - } - ] - }) - - ###* - * Activates the package. - ### - activate: -> - return packageDeps.install(@packageName, true).then () => - promise = @installCoreIfNecessary() - - promise.then () => - @doActivate() - - promise.catch () => - return - - return promise - - ###* - * Does the actual activation. - ### - doActivate: () -> - @notifyAboutRedundantNavigationPackageIfNecessary() - @notifyAboutRedundantAutocompletionPackageIfNecessary() - @notifyAboutRedundantAnnotationsPackageIfNecessary() - @notifyAboutRedundantRefactoringPackageIfNecessary() - - @registerCommands() - @registerConfigListeners() - @registerStatusBarListeners() - - @editorTimeoutMap = {} - - @registerAtomListeners() - - if @getConfiguration().get('datatips.enable') - @activateDatatips() - - if @getConfiguration().get('signatureHelp.enable') - @activateSignatureHelp() - - if @getConfiguration().get('annotations.enable') - @activateAnnotations() - - if @getConfiguration().get('linting.enable') - @activateLinting() - - if @getConfiguration().get('refactoring.enable') - @activateRefactoring() - - if @getConfiguration().get('gotoDefinition.enable') - @activateGotoDefinition() - - if @getConfiguration().get('autocompletion.enable') - @activateAutocompletion() - - @getProxy().setIsActive(true) - - # This fixes the corner case where the core is still installing, the project manager service has already - # loaded and the project is already active. At that point, the index that resulted from it silently - # failed because the proxy (and core) weren't active yet. This in turn causes the project to not - # automatically start indexing, which is especially relevant if a core update requires a reindex. - if @activeProject? - @changeActiveProject(@activeProject) - - ###* - * Registers listeners for events from Atom's API. - ### - registerAtomListeners: () -> - @getDisposables().add atom.workspace.observeTextEditors (editor) => - @registerTextEditorListeners(editor) - - ###* - * Activates the datatip provider. - ### - activateDatatips: () -> - @getDatatipProvider().activate(@getService()) - - ###* - * Deactivates the datatip provider. - ### - deactivateDatatips: () -> - @getDatatipProvider().deactivate() - - ###* - * Activates the signature help provider. - ### - activateSignatureHelp: () -> - @getSignatureHelpProvider().activate(@getService()) - - ###* - * Deactivates the signature help provider. - ### - deactivateSignatureHelp: () -> - @getSignatureHelpProvider().deactivate() - - ###* - * Activates the goto definition provider. - ### - activateGotoDefinition: () -> - @getGotoDefinitionProvider().activate(@getService()) - - ###* - * Deactivates the goto definition provider. - ### - deactivateGotoDefinition: () -> - @getGotoDefinitionProvider().deactivate() - - ###* - * Activates the goto definition provider. - ### - activateAutocompletion: () -> - @getAutocompletionProvider().activate(@getService()) - - ###* - * Deactivates the goto definition provider. - ### - deactivateAutocompletion: () -> - @getAutocompletionProvider().deactivate() - - ###* - * Activates annotations. - ### - activateAnnotations: () -> - @annotationProviders = [] - @annotationProviders.push new MethodAnnotationProvider() - @annotationProviders.push new PropertyAnnotationProvider() - - for provider in @annotationProviders - provider.activate(@getService()) - - ###* - * Deactivates annotations. - ### - deactivateAnnotations: () -> - for provider in @annotationProviders - provider.deactivate() - - @annotationProviders = [] - - ###* - * Activates refactoring. - ### - activateRefactoring: () -> - @getRefactoringBuilder().setService(@getService()) - @getRefactoringTypeHelper().setService(@getService()) - - for provider in @getRefactoringProviders() - provider.activate(@getService()) - - ###* - * Deactivates refactoring. - ### - deactivateRefactoring: () -> - for provider in @getRefactoringProviders() - provider.deactivate() - - @refactoringProviders = null - - ###* - * Activates linting. - ### - activateLinting: () -> - @getLinterProvider().activate(@getService()) - - ###* - * Deactivates linting. - ### - deactivateLinting: () -> - @getLinterProvider().deactivate() - - ###* - * @param {TextEditor} editor - ### - registerTextEditorListeners: (editor) -> - if @getConfiguration().get('general.indexContinuously') == true - # The default onDidStopChanging timeout is 300 milliseconds. As this is notcurrently configurable (and would - # also impact other packages), we install our own timeout on top of the existing one. This is useful for users - # that don't type particularly fast or are on slower machines and will prevent constant indexing from happening. - @getDisposables().add editor.onDidStopChanging () => - path = editor.getPath() - - additionalIndexingDelay = @getConfiguration().get('general.additionalIndexingDelay') - - @editorTimeoutMap[path] = setTimeout ( => - @onEditorDidStopChanging(editor) - @editorTimeoutMap[path] = null - ), additionalIndexingDelay - - @getDisposables().add editor.onDidChange () => - path = editor.getPath() - - if @editorTimeoutMap[path]? - clearTimeout(@editorTimeoutMap[path]) - @editorTimeoutMap[path] = null - - else - @getDisposables().add editor.onDidSave(@onEditorDidStopChanging.bind(this, editor)) - - ###* - * Invoked when an editor stops changing. - * - * @param {TextEditor} editor - ### - onEditorDidStopChanging: (editor) -> - return unless /text.html.php$/.test(editor.getGrammar().scopeName) - - fileName = editor.getPath() - - return if not fileName - - projectManager = @getProjectManager() - - if projectManager.hasActiveProject() and projectManager.isFilePartOfCurrentProject(fileName) - projectManager.attemptCurrentProjectFileIndex(fileName, editor.getBuffer().getText()) - - ###* - * Deactivates the package. - ### - deactivate: -> - if @disposables? - @disposables.dispose() - @disposables = null - - @deactivateLinting() - @deactivateDatatips() - @deactivateSignatureHelp() - @deactivateAnnotations() - @deactivateRefactoring() - - @getProxy().exit() - - return - - ###* - * @param {mixed} service - * - * @return {Disposable} - ### - setLinterIndieService: (service) -> - linter = service({ - name: 'Serenata' - }) - - @getDisposables().add(linter) - - @getLinterProvider().setIndieLinter(linter) - - ###* - * Sets the project manager service. - * - * @param {Object} service - ### - setProjectManagerService: (service) -> - @projectManagerService = service - - # NOTE: This method is actually called whenever the project changes as well. - service.getProject (project) => - @onProjectChanged(project) - - ###* - * @param {Object} project - ### - onProjectChanged: (project) -> - @changeActiveProject(project) - - ###* - * @param {Object} project - ### - changeActiveProject: (project) -> - @activeProject = project - - return if not project? - - projectManager = @getProjectManager() - projectManager.load(project) - - return if not projectManager.hasActiveProject() - - successHandler = (isProjectInGoodShape) => - # NOTE: If the index is manually deleted, testing will return false so the project is reinitialized. - # This is needed to index built-in items as they are not automatically indexed by indexing the project. - if not isProjectInGoodShape - return @performInitialFullIndexForCurrentProject() - - else - return @projectManager.attemptCurrentProjectIndex() - - failureHandler = () -> - # Ignore - - @proxy.test().then(successHandler, failureHandler) - - return - - ###* - * Retrieves autocompletion providers for the autocompletion package. - * - * @return {Array} - ### - getAutocompletionProviderServices: () -> - return [@getAutocompletionProvider()] - - ###* - * @param {Object} signatureHelpService - ### - consumeSignatureHelpService: (signatureHelpService) -> - signatureHelpService(@getSignatureHelpProvider()) - - ###* - * @param {Object} busySignalService - ### - consumeBusySignalService: (busySignalService) -> - @busySignalService = busySignalService - - ###* - * @param {Object} datatipService - ### - consumeDatatipService: (datatipService) -> - datatipService.addProvider(@getDatatipProvider()) - - ###* - * Returns the hyperclick provider. - * - * @return {Object} - ### - getHyperclickProvider: () -> - return @getGotoDefinitionProvider() - - ###* - * Consumes the atom/snippet service. - * - * @param {Object} snippetManager - ### - setSnippetManager: (snippetManager) -> - for provider in @getRefactoringProviders() - provider.setSnippetManager(snippetManager) - - ###* - * Returns a list of intention providers. - * - * @return {Array} - ### - provideIntentions: () -> - intentionProviders = [] - - for provider in @getRefactoringProviders() - intentionProviders = intentionProviders.concat(provider.getIntentionProviders()) - - return intentionProviders - - ###* - * @return {Service} - ### - getService: () -> - if not @service? - @service = new Service( - @getConfiguration(), - @getProxy(), - @getProjectManager(), - @getIndexingMediator(), - @getUseStatementHelper() - ) - - return @service - - ###* - * @return {Disposables} - ### - getDisposables: () -> - if not @disposables? - @disposables = new CompositeDisposable() - - return @disposables - - ###* - * @return {Configuration} - ### - getConfiguration: () -> - if not @configuration? - @configuration = new AtomConfig(@packageName) - - return @configuration - - ###* - * @return {Configuration} - ### - getPhpInvoker: () -> - if not @phpInvoker? - @phpInvoker = new PhpInvoker(@getConfiguration()) - - return @phpInvoker - - ###* - * @return {Proxy} - ### - getProxy: () -> - if not @proxy? - @proxy = new Proxy(@getConfiguration(), @getPhpInvoker()) - @proxy.setCorePath(@getCoreManager().getCoreSourcePath()) - - return @proxy - - ###* - * @return {Emitter} - ### - getEmitter: () -> - if not @emitter? - @emitter = new Emitter() - - return @emitter - - ###* - * @return {ComposerService} - ### - getComposerService: () -> - if not @composerService? - @composerService = new ComposerService( - @getPhpInvoker(), - @getConfiguration().get('storagePath') + '/core/' - ) - - return @composerService - - ###* - * @return {CoreManager} - ### - getCoreManager: () -> - if not @coreManager? - @coreManager = new CoreManager( - @getComposerService(), - @coreVersionSpecification, - @getConfiguration().get('storagePath') + '/core/' - ) - - return @coreManager - - ###* - * @return {UseStatementHelper} - ### - getUseStatementHelper: () -> - if not @useStatementHelper? - @useStatementHelper = new UseStatementHelper(true) - - return @useStatementHelper - - ###* - * @return {IndexingMediator} - ### - getIndexingMediator: () -> - if not @indexingMediator? - @indexingMediator = new IndexingMediator(@getProxy(), @getEmitter()) - - return @indexingMediator - - ###* - * @return {ProjectManager} - ### - getProjectManager: () -> - if not @projectManager? - @projectManager = new ProjectManager(@getProxy(), @getIndexingMediator()) - - return @projectManager - - ###* - * @return {DatatipProvider} - ### - getDatatipProvider: () -> - if not @datatipProvider? - @datatipProvider = new DatatipProvider() - - return @datatipProvider - - ###* - * @return {SignatureHelpProvider} - ### - getSignatureHelpProvider: () -> - if not @signatureHelpProvider? - @signatureHelpProvider = new SignatureHelpProvider() - - return @signatureHelpProvider - - ###* - * @return {GotoDefinitionProvider} - ### - getGotoDefinitionProvider: () -> - if not @gotoDefinitionProvider? - @gotoDefinitionProvider = new GotoDefinitionProvider(@getPhpInvoker()) - - return @gotoDefinitionProvider - - ###* - * @return {LinterProvider} - ### - getLinterProvider: () -> - if not @linterProvider? - @linterProvider = new LinterProvider(@getConfiguration()) - - return @linterProvider - - ###* - * @return {AutocompletionProvider} - ### - getAutocompletionProvider: () -> - if not @autocompletionProvider? - @autocompletionProvider = new AutocompletionProvider() - - return @autocompletionProvider - - ###* - * @return {TypeHelper} - ### - getRefactoringTypeHelper: () -> - if not @typeHelper? - @typeHelper = new TypeHelper() - - return @typeHelper - - ###* - * @return {DocblockBuilder} - ### - getRefactoringDocblockBuilder: () -> - if not @docblockBuilder? - @docblockBuilder = new DocblockBuilder() - - return @docblockBuilder - - ###* - * @return {FunctionBuilder} - ### - getRefactoringFunctionBuilder: () -> - if not @functionBuilder? - @functionBuilder = new FunctionBuilder() - - return @functionBuilder - - ###* - * @return {ParameterParser} - ### - getRefactoringParameterParser: () -> - if not @parameterParser? - @parameterParser = new ParameterParser(@getRefactoringTypeHelper()) - - return @parameterParser - - ###* - * @return {Builder} - ### - getRefactoringBuilder: () -> - if not @builder? - @builder = new Builder( - @getRefactoringParameterParser(), - @getRefactoringDocblockBuilder(), - @getRefactoringFunctionBuilder(), - @getRefactoringTypeHelper() - ) - - return @builder - - ###* - * @return {Array} - ### - getRefactoringProviders: () -> - if not @refactoringProviders? - @refactoringProviders = [] - @refactoringProviders.push new DocblockProvider(@getRefactoringTypeHelper(), @getRefactoringDocblockBuilder()) - @refactoringProviders.push new IntroducePropertyProvider(@getRefactoringDocblockBuilder()) - @refactoringProviders.push new GetterSetterProvider(@getRefactoringTypeHelper(), @getRefactoringFunctionBuilder(), @getRefactoringDocblockBuilder()) - @refactoringProviders.push new ExtractMethodProvider(@getRefactoringBuilder()) - @refactoringProviders.push new ConstructorGenerationProvider(@getRefactoringTypeHelper(), @getRefactoringFunctionBuilder(), @getRefactoringDocblockBuilder()) - - @refactoringProviders.push new OverrideMethodProvider(@getRefactoringDocblockBuilder(), @getRefactoringFunctionBuilder()) - @refactoringProviders.push new StubAbstractMethodProvider(@getRefactoringDocblockBuilder(), @getRefactoringFunctionBuilder()) - @refactoringProviders.push new StubInterfaceMethodProvider(@getRefactoringDocblockBuilder(), @getRefactoringFunctionBuilder()) - - return @refactoringProviders diff --git a/lib/Main.js b/lib/Main.js new file mode 100644 index 00000000..5873826a --- /dev/null +++ b/lib/Main.js @@ -0,0 +1,1684 @@ +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +const {Disposable, CompositeDisposable} = require('atom'); + +const {Emitter} = require('event-kit'); + +const packageDeps = require('atom-package-deps'); + +const fs = require('fs'); +const process = require('process'); + +const Proxy = require('./Proxy'); +const Service = require('./Service'); +const AtomConfig = require('./AtomConfig'); +const PhpInvoker = require('./PhpInvoker'); +const CoreManager = require('./CoreManager'); +const ConfigTester = require('./ConfigTester'); +const ProjectManager = require('./ProjectManager'); +const LinterProvider = require('./LinterProvider'); +const ComposerService = require('./ComposerService'); +const DatatipProvider = require('./DatatipProvider'); +const IndexingMediator = require('./IndexingMediator'); +const UseStatementHelper = require('./UseStatementHelper'); +const SignatureHelpProvider = require('./SignatureHelpProvider'); +const GotoDefinitionProvider = require('./GotoDefinitionProvider'); +const AutocompletionProvider = require('./AutocompletionProvider'); + +const MethodAnnotationProvider = require('./Annotations/MethodAnnotationProvider'); +const PropertyAnnotationProvider = require('./Annotations/PropertyAnnotationProvider'); + +const DocblockProvider = require('./Refactoring/DocblockProvider'); +const GetterSetterProvider = require('./Refactoring/GetterSetterProvider'); +const ExtractMethodProvider = require('./Refactoring/ExtractMethodProvider'); +const OverrideMethodProvider = require('./Refactoring/OverrideMethodProvider'); +const IntroducePropertyProvider = require('./Refactoring/IntroducePropertyProvider'); +const StubAbstractMethodProvider = require('./Refactoring/StubAbstractMethodProvider'); +const StubInterfaceMethodProvider = require('./Refactoring/StubInterfaceMethodProvider'); +const ConstructorGenerationProvider = require('./Refactoring/ConstructorGenerationProvider'); + +const Builder = require('./Refactoring/ExtractMethodProvider/Builder'); +const TypeHelper = require('./Refactoring/Utility/TypeHelper'); +const DocblockBuilder = require('./Refactoring/Utility/DocblockBuilder'); +const FunctionBuilder = require('./Refactoring/Utility/FunctionBuilder'); +const ParameterParser = require('./Refactoring/ExtractMethodProvider/ParameterParser'); + +module.exports = { + /** + * Configuration settings. + */ + config: { + core: { + type: 'object', + order: 1, + properties: { + phpExecutionType: { + title : 'PHP execution type', + description : `How to start PHP, which is needed to start the core in turn. \n \n \ +\ +'Use PHP on the host' uses a PHP binary installed on your local machine. 'Use PHP \ +container via Docker' requires Docker and uses a PHP container to start the server \ +with. Using PolicyKit allows Linux users that are not part of the Docker group to \ +enter their password via an authentication dialog to temporarily escalate privileges \ +so the Docker daemon can be invoked once to start the server. \n \n \ +\ +You can use the php-ide-serenata:test-configuration command to test your setup. \ +\n \n \ +\ +Requires a restart after changing. \n \n`, + type : 'string', + default : 'host', + order : 1, + enum : [ + { + value : 'host', + description : 'Use PHP on the host' + }, + + { + value : 'docker', + description : 'Use a PHP container via Docker (experimental)' + }, + + { + value : 'docker-polkit', + description : 'Use a PHP container via Docker, using PolicyKit for privilege escalation ' + + ' (experimental, Linux only)' + } + ] + }, + + phpCommand: { + title : 'PHP command', + description : `The path to your PHP binary (e.g. /usr/bin/php, php, ...). Only applies if you\'ve \ +selected "Use PHP on the host" above. \n \n \ +\ +Requires a restart after changing.`, + type : 'string', + default : 'php', + order : 2 + }, + + memoryLimit: { + title : 'Memory limit (in MB)', + description : `The memory limit to set for the PHP process. The PHP process uses the available \ +memory for in-memory caching as well, so it should not be too low. On the other hand, \ +it shouldn\'t be growing very large, so setting it to -1 is probably a bad idea as \ +an infinite loop bug might take down your system. The default should suit most \ +projects, from small to large. \n \n \ +Requires a restart after changing.`, + type : 'integer', + default : 2048, + order : 3 + }, + + additionalDockerVolumes: { + title : 'Additional Docker volumes', + description : `Additional paths to mount as Docker volumes. Only applies when using Docker to run \ +the core. Separate these using comma\'s, where each item follows the format \ +"src:dest" as the Docker -v flag uses. \n \n \ +Requires a restart after changing.`, + type : 'array', + default : [], + order : 4, + items : { + type : 'string' + } + } + } + }, + + general: { + type: 'object', + order: 2, + properties: { + indexContinuously: { + title : 'Index continuously', + description : `If enabled, indexing will happen continuously and automatically whenever the editor \ +is modified. If disabled, indexing will only happen on save. This also influences \ +linting, which happens automatically after indexing completes. In other words, if \ +you would like linting to happen on save, you can disable this option.`, + type : 'boolean', + default : true, + order : 1 + }, + + additionalIndexingDelay: { + title : 'Additional delay before reindexing (in ms)', + description : `Only applies when indexing continously, which happens after a fixed time (about 300 \ +ms at the time of writing and managed by Atom). If this is too fast for you, you can \ +introduce an additional delay here. Fewer indexes means less load as tasks such as \ +linting are invoked less often. However, it also means that it will take longer for \ +changes to code to be reflected in, for example, autocompletion.`, + type : 'integer', + default : 500, + order : 2 + } + } + }, + + datatips: { + type: 'object', + order: 3, + properties: { + enable: { + title : 'Enable', + description : `When enabled, documentation for various structural elements can be displayed in a \ +datatip (tooltip).`, + type : 'boolean', + default : true, + order : 1 + } + } + }, + + signatureHelp: { + type: 'object', + order: 4, + properties: { + enable: { + title : 'Enable', + description : `When enabled, signature help (call tips) will be displayed when the keyboard cursor \ +is inside a function, method or constructor call.`, + type : 'boolean', + default : true, + order : 1 + } + } + }, + + gotoDefinition: { + type: 'object', + order: 5, + properties: { + enable: { + title : 'Enable', + description : 'When enabled, code navigation will be activated via the hyperclick package.', + type : 'boolean', + default : true, + order : 1 + } + } + }, + + autocompletion: { + type: 'object', + order: 6, + properties: { + enable: { + title : 'Enable', + description : 'When enabled, autocompletion will be activated via the autocomplete-plus package.', + type : 'boolean', + default : true, + order : 1 + } + } + }, + + annotations: { + type: 'object', + order: 7, + properties: { + enable: { + title : 'Enable', + description : `When enabled, annotations will be shown in the gutter with more information \ +regarding member overrides and interface implementations.`, + type : 'boolean', + default : true, + order : 1 + } + } + }, + + refactoring: { + type: 'object', + order: 8, + properties: { + enable: { + title : 'Enable', + description : 'When enabled, refactoring actions will be available via the intentions package.', + type : 'boolean', + default : true, + order : 1 + } + } + }, + + linting: { + type: 'object', + order: 9, + properties: { + enable: { + title : 'Enable', + description : 'When enabled, linting will show problems and warnings picked up in your code.', + type : 'boolean', + default : true, + order : 1 + }, + + showUnknownClasses: { + title : 'Show unknown classes', + description : 'Highlights class names that could not be found. This will also work for docblocks.', + type : 'boolean', + default : true, + order : 2 + }, + + showUnknownGlobalFunctions: { + title : 'Show unknown (global) functions', + description : 'Highlights (global) functions that could not be found.', + type : 'boolean', + default : true, + order : 3 + }, + + showUnknownGlobalConstants: { + title : 'Show unknown (global) constants', + description : 'Highlights (global) constants that could not be found.', + type : 'boolean', + default : true, + order : 4 + }, + + showUnusedUseStatements: { + title : 'Show unused use statements', + description : 'Highlights use statements that don\'t seem to be used anywhere.', + type : 'boolean', + default : true, + order : 5 + }, + + showMissingDocs: { + title : 'Show missing documentation', + description : 'Warns about structural elements that are missing documentation.', + type : 'boolean', + default : true, + order : 6 + }, + + validateDocblockCorrectness: { + title : 'Validate docblock correctness', + description : `\ +Analyzes the correctness of docblocks of various structural elements and will show various +problems such as undocumented parameters, mismatched parameter and deprecated tags.\ +`, + type : 'boolean', + default : true, + order : 7 + }, + + showUnknownMembers: { + title : 'Show unknown members (experimental)', + description : `\ +Highlights use of unknown members. Note that this can be a large strain on performance and is +experimental (expect false positives, especially inside conditionals).\ +`, + type : 'boolean', + default : false, + order : 8 + } + } + } + }, + + /** + * The version of the core to download (version specification string). + * + * @var {String} + */ + coreVersionSpecification: "4.0.1", + + /** + * The name of the package. + * + * @var {String} + */ + packageName: 'php-ide-serenata', + + /** + * The configuration object. + * + * @var {Object} + */ + configuration: null, + + /** + * @var {Object} + */ + PhpInvoker: null, + + /** + * The proxy object. + * + * @var {Object} + */ + proxy: null, + + /** + * The exposed service. + * + * @var {Object} + */ + service: null, + + /** + * @var {IndexingMediator} + */ + indexingMediator: null, + + /** + * A list of disposables to dispose when the package deactivates. + * + * @var {Object|null} + */ + disposables: null, + + /** + * The currently active project, if any. + * + * @var {Object|null} + */ + activeProject: null, + + /** + * @var {String|null} + */ + timerName: null, + + /** + * @var {Object|null} + */ + typeHelper: null, + + /** + * @var {Object|null} + */ + docblockBuilder: null, + + /** + * @var {Object|null} + */ + functionBuilder: null, + + /** + * @var {Object|null} + */ + parameterParser: null, + + /** + * @var {Object|null} + */ + builder: null, + + /** + * The service instance from the project-manager package. + * + * @var {Object|null} + */ + projectManagerService: null, + + /** + * @var {Object|null} + */ + editorTimeoutMap: null, + + /** + * @var {Object|null} + */ + datatipProvider: null, + + /** + * @var {Object|null} + */ + signatureHelpProvider: null, + + /** + * @var {Object|null} + */ + gotoDefinitionProvider: null, + + /** + * @var {Array|null} + */ + annotationProviders: null, + + /** + * @var {Array|null} + */ + refactoringProviders: null, + + /** + * @var {Object|null} + */ + linterProvider: null, + + /** + * @var {Object|null} + */ + busySignalService: null, + + /** + * Tests the user's configuration. + */ + testConfig() { + const configTester = new ConfigTester(this.getPhpInvoker()); + + atom.notifications.addInfo('Serenata - Testing Configuration', { + dismissable: true, + detail: 'Now testing your configuration... \n \n' + + + `If you\'ve selected Docker, this may take a while the first time \ +as the Docker image has to be fetched first.` + }); + + const callback = () => { + return configTester.test().then(wasSuccessful => { + if (!wasSuccessful) { + const errorMessage = + `PHP is not configured correctly. Please visit the settings screen to correct this error. If you are \ +using a relative path to PHP, make sure it is in your PATH variable.`; + + return atom.notifications.addError('Serenata - Failure', {dismissable: true, detail: errorMessage}); + + } else { + return atom.notifications.addSuccess('Serenata - Success', { + dismissable: true, + detail: 'Your setup is working correctly.' + }); + } + }); + }; + + return this.busySignalService.reportBusyWhile('Testing your configuration...', callback, { + waitingFor : 'computer', + revealTooltip : false + }); + }, + + /** + * Registers any commands that are available to the user. + */ + registerCommands() { + atom.commands.add('atom-workspace', { "php-ide-serenata:set-up-current-project": () => { + let errorMessage; + if ((this.projectManagerService == null)) { + errorMessage = `\ +The project manager service was not found. Did you perhaps forget to install the project-manager +package or another package able to provide it?\ +`; + + atom.notifications.addError('Incorrect setup!', {'detail': errorMessage}); + return; + } + + if ((this.activeProject == null)) { + errorMessage = `\ +No project is currently active. Please save and activate one before attempting to set it up. +You can do it via the menu Packages → Project Manager → Save Project.\ +`; + + atom.notifications.addError('Incorrect setup!', {'detail': errorMessage}); + return; + } + + const project = this.activeProject; + + let newProperties = null; + + try { + newProperties = this.projectManager.setUpProject(project); + + if ((newProperties == null)) { + throw new Error('No properties returned, this should never happen!'); + } + + } catch (error) { + atom.notifications.addError('Error!', { + 'detail' : error.message + }); + + return; + } + + this.projectManagerService.saveProject(newProperties); + + atom.notifications.addSuccess('Success', { + 'detail' : 'Your current project has been set up as PHP project. Indexing will now commence.' + }); + + this.projectManager.load(project); + + return this.performInitialFullIndexForCurrentProject(); + } + } + ); + + atom.commands.add('atom-workspace', { "php-ide-serenata:index-project": () => { + if (!this.projectManager.hasActiveProject()) { return; } + + return this.projectManager.attemptCurrentProjectIndex(); + } + } + ); + + atom.commands.add('atom-workspace', { "php-ide-serenata:force-index-project": () => { + if (!this.projectManager.hasActiveProject()) { return; } + + return this.performInitialFullIndexForCurrentProject(); + } + } + ); + + atom.commands.add('atom-workspace', { "php-ide-serenata:test-configuration": () => { + return this.testConfig(); + } + } + ); + + return atom.commands.add('atom-workspace', { "php-ide-serenata:sort-use-statements": () => { + const activeTextEditor = atom.workspace.getActiveTextEditor(); + + if ((activeTextEditor == null)) { return; } + + return this.getUseStatementHelper().sortUseStatements(activeTextEditor); + } + } + ); + }, + + /** + * Performs the "initial" index for a new project by initializing it and then performing a project index. + * + * @return {Promise} + */ + performInitialFullIndexForCurrentProject() { + const successHandler = () => { + return this.projectManager.attemptCurrentProjectIndex(); + }; + + const failureHandler = reason => { + console.error(reason); + + return atom.notifications.addError('Error!', { + 'detail' : 'The project could not be properly initialized!' + }); + }; + + return this.projectManager.initializeCurrentProject().then(successHandler, failureHandler); + }, + + /** + * Registers listeners for configuration changes. + */ + registerConfigListeners() { + const config = this.getConfiguration(); + + config.onDidChange('datatips.enable', value => { + if (value) { + return this.activateDatatips(); + + } else { + return this.deactivateDatatips(); + } + }); + + config.onDidChange('signatureHelp.enable', value => { + if (value) { + return this.activateSignatureHelp(); + + } else { + return this.deactivateSignatureHelp(); + } + }); + + config.onDidChange('gotoDefintion.enable', value => { + if (value) { + return this.activateGotoDefinition(); + + } else { + return this.deactivateGotoDefinition(); + } + }); + + config.onDidChange('autocompletion.enable', value => { + if (value) { + return this.activateAutocompletion(); + + } else { + return this.deactivateAutocompletion(); + } + }); + + config.onDidChange('annotations.enable', value => { + if (value) { + return this.activateAnnotations(); + + } else { + return this.deactivateAnnotations(); + } + }); + + config.onDidChange('refactoring.enable', value => { + if (value) { + return this.activateRefactoring(); + + } else { + return this.deactivateRefactoring(); + } + }); + + return config.onDidChange('linting.enable', value => { + if (value) { + return this.activateLinting(); + + } else { + return this.deactivateLinting(); + } + }); + }, + + /** + * Registers status bar listeners. + */ + registerStatusBarListeners() { + const service = this.getService(); + + const indexBusyMessageMap = new Map(); + + const getBaseMessageForPath = function(path) { + if (Array.isArray(path)) { + path = path[0]; + } + + if (path.indexOf('~') !== false) { + path = path.replace('~', process.env.HOME); + } + + if (fs.lstatSync(path).isDirectory()) { + return 'Indexing project - code assistance may be unavailable or incomplete'; + } + + return `Indexing ${path}`; + }; + + service.onDidStartIndexing(({path}) => { + if (!indexBusyMessageMap.has(path)) { + indexBusyMessageMap.set(path, new Array()); + } + + return indexBusyMessageMap.get(path).push(this.busySignalService.reportBusy(getBaseMessageForPath(path), { + waitingFor : 'computer', + revealTooltip : true + })); + }); + + service.onDidFinishIndexing(({path}) => { + if (!indexBusyMessageMap.has(path)) { return; } + + indexBusyMessageMap.get(path).forEach(busyMessage => busyMessage.dispose()); + return indexBusyMessageMap.delete(path); + }); + + service.onDidFailIndexing(({path}) => { + if (!indexBusyMessageMap.has(path)) { return; } + + indexBusyMessageMap.get(path).forEach(busyMessage => busyMessage.dispose()); + return indexBusyMessageMap.delete(path); + }); + + return service.onDidIndexingProgress(({path, percentage}) => { + if (!indexBusyMessageMap.has(path)) { return; } + + return indexBusyMessageMap.get(path).forEach(busyMessage => { + return busyMessage.setTitle(getBaseMessageForPath(path) + " (" + percentage.toFixed(2) + " %)"); + }); + }); + }, + + /** + * @return {Promise} + */ + installCoreIfNecessary() { + return new Promise((resolve, reject) => { + let notification; + if (this.getCoreManager().isInstalled()) { + resolve(); + return; + } + + const message = + "The core isn't installed yet or is outdated. I can install the latest version for you " + + "automatically.\n \n" + + + "First time using this package? Please visit the package settings to set up PHP correctly first."; + + return notification = atom.notifications.addInfo('Serenata - Core Installation', { + detail : message, + dismissable : true, + + buttons: [ + { + text: 'Open package settings', + onDidClick: () => { + return atom.workspace.open(`atom://config/packages/${this.packageName}`); + } + }, + + { + text: 'Test my setup', + onDidClick: () => { + return this.testConfig(); + } + }, + + { + text: 'Ready, install the core', + onDidClick: () => { + notification.dismiss(); + + const callback = () => { + const promise = this.installCore(); + + promise.catch(() => { + return reject(new Error('Core installation failed')); + }); + + return promise.then(() => { + return resolve(); + }); + }; + + if (this.busySignalService) { + return this.busySignalService.reportBusyWhile('Installing the core...', callback, { + waitingFor : 'computer', + revealTooltip : false + }); + + } else { + return console.warn( + 'Busy signal service not loaded yet whilst installing core, not showing ' + + 'loading spinner' + ); + } + } + }, + + { + text: 'No, go away', + onDidClick: () => { + notification.dismiss(); + return reject(); + } + } + ] + }); + }); + }, + + /** + * @return {Promise} + */ + installCore() { + let message = + "The core is being downloaded and installed. To do this, Composer is automatically downloaded and " + + "installed into a temporary folder.\n \n" + + + "Progress and output is sent to the developer tools console, in case you'd like to monitor it.\n \n" + + + "You will be notified once the install finishes (or fails)."; + + atom.notifications.addInfo('Serenata - Installing Core', {'detail': message, dismissable: true}); + + const successHandler = () => atom.notifications.addSuccess('Serenata - Core Installation Succeeded', {dismissable: true}); + + const failureHandler = function() { + message = + "Installation of the core failed. This can happen for a variety of reasons, such as an outdated PHP " + + "version or missing extensions.\n \n" + + + "Logs in the developer tools will likely provide you with more information about what is wrong. You " + + "can open it via the menu View → Developer → Toggle Developer Tools.\n \n" + + + "Additionally, the README provides more information about requirements and troubleshooting."; + + return atom.notifications.addError('Serenata - Core Installation Failed', {detail: message, dismissable: true}); + }; + + return this.getCoreManager().install().then(successHandler, failureHandler); + }, + + /** + * Checks if the php-integrator-navigation package is installed and notifies the user it is obsolete if it is. + */ + notifyAboutRedundantNavigationPackageIfNecessary() { + return atom.packages.onDidActivatePackage(function(packageData) { + let notification; + if (packageData.name !== 'php-integrator-navigation') { return; } + + const message = + "It seems you still have the php-integrator-navigation package installed and activated. As of this " + + "release, it is obsolete and all its functionality is already included in the base package.\n \n" + + + "It is recommended to disable or remove it, shall I disable it for you?"; + + return notification = atom.notifications.addInfo('Serenata - Navigation', { + detail : message, + dismissable : true, + + buttons: [ + { + text: 'Yes, nuke it', + onDidClick() { + atom.packages.disablePackage('php-integrator-navigation'); + return notification.dismiss(); + } + }, + + { + text: 'No, don\'t touch it', + onDidClick() { + return notification.dismiss(); + } + } + ] + }); + }); + }, + + /** + * Checks if the php-integrator-autocomplete-plus package is installed and notifies the user it is obsolete if it + * is. + */ + notifyAboutRedundantAutocompletionPackageIfNecessary() { + return atom.packages.onDidActivatePackage(function(packageData) { + let notification; + if (packageData.name !== 'php-integrator-autocomplete-plus') { return; } + + const message = + "It seems you still have the php-integrator-autocomplete-plus package installed and activated. As of " + + "this release, it is obsolete and all its functionality is already included in the base package.\n \n" + + + "It is recommended to disable or remove it, shall I disable it for you?"; + + return notification = atom.notifications.addInfo('Serenata - Autocompletion', { + detail : message, + dismissable : true, + + buttons: [ + { + text: 'Yes, nuke it', + onDidClick() { + atom.packages.disablePackage('php-integrator-autocomplete-plus'); + return notification.dismiss(); + } + }, + + { + text: 'No, don\'t touch it', + onDidClick() { + return notification.dismiss(); + } + } + ] + }); + }); + }, + + /** + * Checks if the php-integrator-annotations package is installed and notifies the user it is obsolete if it + * is. + */ + notifyAboutRedundantAnnotationsPackageIfNecessary() { + return atom.packages.onDidActivatePackage(function(packageData) { + let notification; + if (packageData.name !== 'php-integrator-annotations') { return; } + + const message = + "It seems you still have the php-integrator-annotations package installed and activated. As of " + + "this release, it is obsolete and all its functionality is already included in the base package.\n \n" + + + "It is recommended to disable or remove it, shall I disable it for you?"; + + return notification = atom.notifications.addInfo('Serenata - Autocompletion', { + detail : message, + dismissable : true, + + buttons: [ + { + text: 'Yes, nuke it', + onDidClick() { + atom.packages.disablePackage('php-integrator-annotations'); + return notification.dismiss(); + } + }, + + { + text: 'No, don\'t touch it', + onDidClick() { + return notification.dismiss(); + } + } + ] + }); + }); + }, + + /** + * Checks if the php-integrator-refactoring package is installed and notifies the user it is obsolete if it + * is. + */ + notifyAboutRedundantRefactoringPackageIfNecessary() { + return atom.packages.onDidActivatePackage(function(packageData) { + let notification; + if (packageData.name !== 'php-integrator-refactoring') { return; } + + const message = + "It seems you still have the php-integrator-refactoring package installed and activated. As of " + + "this release, it is obsolete and all its functionality is already included in the base package.\n \n" + + + "It is recommended to disable or remove it, shall I disable it for you?"; + + return notification = atom.notifications.addInfo('Serenata - Autocompletion', { + detail : message, + dismissable : true, + + buttons: [ + { + text: 'Yes, nuke it', + onDidClick() { + atom.packages.disablePackage('php-integrator-refactoring'); + return notification.dismiss(); + } + }, + + { + text: 'No, don\'t touch it', + onDidClick() { + return notification.dismiss(); + } + } + ] + }); + }); + }, + + /** + * Activates the package. + */ + activate() { + return packageDeps.install(this.packageName, true).then(() => { + const promise = this.installCoreIfNecessary(); + + promise.then(() => { + return this.doActivate(); + }); + + promise.catch(() => { + }); + + return promise; + }); + }, + + /** + * Does the actual activation. + */ + doActivate() { + this.notifyAboutRedundantNavigationPackageIfNecessary(); + this.notifyAboutRedundantAutocompletionPackageIfNecessary(); + this.notifyAboutRedundantAnnotationsPackageIfNecessary(); + this.notifyAboutRedundantRefactoringPackageIfNecessary(); + + this.registerCommands(); + this.registerConfigListeners(); + this.registerStatusBarListeners(); + + this.editorTimeoutMap = {}; + + this.registerAtomListeners(); + + if (this.getConfiguration().get('datatips.enable')) { + this.activateDatatips(); + } + + if (this.getConfiguration().get('signatureHelp.enable')) { + this.activateSignatureHelp(); + } + + if (this.getConfiguration().get('annotations.enable')) { + this.activateAnnotations(); + } + + if (this.getConfiguration().get('linting.enable')) { + this.activateLinting(); + } + + if (this.getConfiguration().get('refactoring.enable')) { + this.activateRefactoring(); + } + + if (this.getConfiguration().get('gotoDefinition.enable')) { + this.activateGotoDefinition(); + } + + if (this.getConfiguration().get('autocompletion.enable')) { + this.activateAutocompletion(); + } + + this.getProxy().setIsActive(true); + + // This fixes the corner case where the core is still installing, the project manager service has already + // loaded and the project is already active. At that point, the index that resulted from it silently + // failed because the proxy (and core) weren't active yet. This in turn causes the project to not + // automatically start indexing, which is especially relevant if a core update requires a reindex. + if (this.activeProject != null) { + return this.changeActiveProject(this.activeProject); + } + }, + + /** + * Registers listeners for events from Atom's API. + */ + registerAtomListeners() { + return this.getDisposables().add(atom.workspace.observeTextEditors(editor => { + return this.registerTextEditorListeners(editor); + }) + ); + }, + + /** + * Activates the datatip provider. + */ + activateDatatips() { + return this.getDatatipProvider().activate(this.getService()); + }, + + /** + * Deactivates the datatip provider. + */ + deactivateDatatips() { + return this.getDatatipProvider().deactivate(); + }, + + /** + * Activates the signature help provider. + */ + activateSignatureHelp() { + return this.getSignatureHelpProvider().activate(this.getService()); + }, + + /** + * Deactivates the signature help provider. + */ + deactivateSignatureHelp() { + return this.getSignatureHelpProvider().deactivate(); + }, + + /** + * Activates the goto definition provider. + */ + activateGotoDefinition() { + return this.getGotoDefinitionProvider().activate(this.getService()); + }, + + /** + * Deactivates the goto definition provider. + */ + deactivateGotoDefinition() { + return this.getGotoDefinitionProvider().deactivate(); + }, + + /** + * Activates the goto definition provider. + */ + activateAutocompletion() { + return this.getAutocompletionProvider().activate(this.getService()); + }, + + /** + * Deactivates the goto definition provider. + */ + deactivateAutocompletion() { + return this.getAutocompletionProvider().deactivate(); + }, + + /** + * Activates annotations. + */ + activateAnnotations() { + this.annotationProviders = []; + this.annotationProviders.push(new MethodAnnotationProvider()); + this.annotationProviders.push(new PropertyAnnotationProvider()); + + return Array.from(this.annotationProviders).map((provider) => + provider.activate(this.getService())); + }, + + /** + * Deactivates annotations. + */ + deactivateAnnotations() { + for (let provider of Array.from(this.annotationProviders)) { + provider.deactivate(); + } + + return this.annotationProviders = []; + }, + + /** + * Activates refactoring. + */ + activateRefactoring() { + this.getRefactoringBuilder().setService(this.getService()); + this.getRefactoringTypeHelper().setService(this.getService()); + + return Array.from(this.getRefactoringProviders()).map((provider) => + provider.activate(this.getService())); + }, + + /** + * Deactivates refactoring. + */ + deactivateRefactoring() { + for (let provider of Array.from(this.getRefactoringProviders())) { + provider.deactivate(); + } + + return this.refactoringProviders = null; + }, + + /** + * Activates linting. + */ + activateLinting() { + return this.getLinterProvider().activate(this.getService()); + }, + + /** + * Deactivates linting. + */ + deactivateLinting() { + return this.getLinterProvider().deactivate(); + }, + + /** + * @param {TextEditor} editor + */ + registerTextEditorListeners(editor) { + if (this.getConfiguration().get('general.indexContinuously') === true) { + // The default onDidStopChanging timeout is 300 milliseconds. As this is notcurrently configurable (and would + // also impact other packages), we install our own timeout on top of the existing one. This is useful for users + // that don't type particularly fast or are on slower machines and will prevent constant indexing from happening. + this.getDisposables().add(editor.onDidStopChanging(() => { + const path = editor.getPath(); + + const additionalIndexingDelay = this.getConfiguration().get('general.additionalIndexingDelay'); + + return this.editorTimeoutMap[path] = setTimeout(( () => { + this.onEditorDidStopChanging(editor); + return this.editorTimeoutMap[path] = null; + } + ), additionalIndexingDelay); + }) + ); + + return this.getDisposables().add(editor.onDidChange(() => { + const path = editor.getPath(); + + if (this.editorTimeoutMap[path] != null) { + clearTimeout(this.editorTimeoutMap[path]); + return this.editorTimeoutMap[path] = null; + } + }) + ); + + } else { + return this.getDisposables().add(editor.onDidSave(this.onEditorDidStopChanging.bind(this, editor))); + } + }, + + /** + * Invoked when an editor stops changing. + * + * @param {TextEditor} editor + */ + onEditorDidStopChanging(editor) { + if (!/text.html.php$/.test(editor.getGrammar().scopeName)) { return; } + + const fileName = editor.getPath(); + + if (!fileName) { return; } + + const projectManager = this.getProjectManager(); + + if (projectManager.hasActiveProject() && projectManager.isFilePartOfCurrentProject(fileName)) { + return projectManager.attemptCurrentProjectFileIndex(fileName, editor.getBuffer().getText()); + } + }, + + /** + * Deactivates the package. + */ + deactivate() { + if (this.disposables != null) { + this.disposables.dispose(); + this.disposables = null; + } + + this.deactivateLinting(); + this.deactivateDatatips(); + this.deactivateSignatureHelp(); + this.deactivateAnnotations(); + this.deactivateRefactoring(); + + this.getProxy().exit(); + + }, + + /** + * @param {mixed} service + * + * @return {Disposable} + */ + setLinterIndieService(service) { + const linter = service({ + name: 'Serenata' + }); + + this.getDisposables().add(linter); + + return this.getLinterProvider().setIndieLinter(linter); + }, + + /** + * Sets the project manager service. + * + * @param {Object} service + */ + setProjectManagerService(service) { + this.projectManagerService = service; + + // NOTE: This method is actually called whenever the project changes as well. + return service.getProject(project => { + return this.onProjectChanged(project); + }); + }, + + /** + * @param {Object} project + */ + onProjectChanged(project) { + return this.changeActiveProject(project); + }, + + /** + * @param {Object} project + */ + changeActiveProject(project) { + this.activeProject = project; + + if ((project == null)) { return; } + + const projectManager = this.getProjectManager(); + projectManager.load(project); + + if (!projectManager.hasActiveProject()) { return; } + + const successHandler = isProjectInGoodShape => { + // NOTE: If the index is manually deleted, testing will return false so the project is reinitialized. + // This is needed to index built-in items as they are not automatically indexed by indexing the project. + if (!isProjectInGoodShape) { + return this.performInitialFullIndexForCurrentProject(); + + } else { + return this.projectManager.attemptCurrentProjectIndex(); + } + }; + + const failureHandler = function() {}; + // Ignore + + this.proxy.test().then(successHandler, failureHandler); + + }, + + /** + * Retrieves autocompletion providers for the autocompletion package. + * + * @return {Array} + */ + getAutocompletionProviderServices() { + return [this.getAutocompletionProvider()]; + }, + + /** + * @param {Object} signatureHelpService + */ + consumeSignatureHelpService(signatureHelpService) { + return signatureHelpService(this.getSignatureHelpProvider()); + }, + + /** + * @param {Object} busySignalService + */ + consumeBusySignalService(busySignalService) { + return this.busySignalService = busySignalService; + }, + + /** + * @param {Object} datatipService + */ + consumeDatatipService(datatipService) { + return datatipService.addProvider(this.getDatatipProvider()); + }, + + /** + * Returns the hyperclick provider. + * + * @return {Object} + */ + getHyperclickProvider() { + return this.getGotoDefinitionProvider(); + }, + + /** + * Consumes the atom/snippet service. + * + * @param {Object} snippetManager + */ + setSnippetManager(snippetManager) { + return Array.from(this.getRefactoringProviders()).map((provider) => + provider.setSnippetManager(snippetManager)); + }, + + /** + * Returns a list of intention providers. + * + * @return {Array} + */ + provideIntentions() { + let intentionProviders = []; + + for (let provider of Array.from(this.getRefactoringProviders())) { + intentionProviders = intentionProviders.concat(provider.getIntentionProviders()); + } + + return intentionProviders; + }, + + /** + * @return {Service} + */ + getService() { + if ((this.service == null)) { + this.service = new Service( + this.getConfiguration(), + this.getProxy(), + this.getProjectManager(), + this.getIndexingMediator(), + this.getUseStatementHelper() + ); + } + + return this.service; + }, + + /** + * @return {Disposables} + */ + getDisposables() { + if ((this.disposables == null)) { + this.disposables = new CompositeDisposable(); + } + + return this.disposables; + }, + + /** + * @return {Configuration} + */ + getConfiguration() { + if ((this.configuration == null)) { + this.configuration = new AtomConfig(this.packageName); + this.configuration.load(); + } + + return this.configuration; + }, + + /** + * @return {Configuration} + */ + getPhpInvoker() { + if ((this.phpInvoker == null)) { + this.phpInvoker = new PhpInvoker(this.getConfiguration()); + } + + return this.phpInvoker; + }, + + /** + * @return {Proxy} + */ + getProxy() { + if ((this.proxy == null)) { + this.proxy = new Proxy(this.getConfiguration(), this.getPhpInvoker()); + this.proxy.setCorePath(this.getCoreManager().getCoreSourcePath()); + } + + return this.proxy; + }, + + /** + * @return {Emitter} + */ + getEmitter() { + if ((this.emitter == null)) { + this.emitter = new Emitter(); + } + + return this.emitter; + }, + + /** + * @return {ComposerService} + */ + getComposerService() { + if ((this.composerService == null)) { + this.composerService = new ComposerService( + this.getPhpInvoker(), + this.getConfiguration().get('storagePath') + '/core/' + ); + } + + return this.composerService; + }, + + /** + * @return {CoreManager} + */ + getCoreManager() { + if ((this.coreManager == null)) { + this.coreManager = new CoreManager( + this.getComposerService(), + this.coreVersionSpecification, + this.getConfiguration().get('storagePath') + '/core/' + ); + } + + return this.coreManager; + }, + + /** + * @return {UseStatementHelper} + */ + getUseStatementHelper() { + if ((this.useStatementHelper == null)) { + this.useStatementHelper = new UseStatementHelper(true); + } + + return this.useStatementHelper; + }, + + /** + * @return {IndexingMediator} + */ + getIndexingMediator() { + if ((this.indexingMediator == null)) { + this.indexingMediator = new IndexingMediator(this.getProxy(), this.getEmitter()); + } + + return this.indexingMediator; + }, + + /** + * @return {ProjectManager} + */ + getProjectManager() { + if ((this.projectManager == null)) { + this.projectManager = new ProjectManager(this.getProxy(), this.getIndexingMediator()); + } + + return this.projectManager; + }, + + /** + * @return {DatatipProvider} + */ + getDatatipProvider() { + if ((this.datatipProvider == null)) { + this.datatipProvider = new DatatipProvider(); + } + + return this.datatipProvider; + }, + + /** + * @return {SignatureHelpProvider} + */ + getSignatureHelpProvider() { + if ((this.signatureHelpProvider == null)) { + this.signatureHelpProvider = new SignatureHelpProvider(); + } + + return this.signatureHelpProvider; + }, + + /** + * @return {GotoDefinitionProvider} + */ + getGotoDefinitionProvider() { + if ((this.gotoDefinitionProvider == null)) { + this.gotoDefinitionProvider = new GotoDefinitionProvider(this.getPhpInvoker()); + } + + return this.gotoDefinitionProvider; + }, + + /** + * @return {LinterProvider} + */ + getLinterProvider() { + if ((this.linterProvider == null)) { + this.linterProvider = new LinterProvider(this.getConfiguration()); + } + + return this.linterProvider; + }, + + /** + * @return {AutocompletionProvider} + */ + getAutocompletionProvider() { + if ((this.autocompletionProvider == null)) { + this.autocompletionProvider = new AutocompletionProvider(); + } + + return this.autocompletionProvider; + }, + + /** + * @return {TypeHelper} + */ + getRefactoringTypeHelper() { + if ((this.typeHelper == null)) { + this.typeHelper = new TypeHelper(); + } + + return this.typeHelper; + }, + + /** + * @return {DocblockBuilder} + */ + getRefactoringDocblockBuilder() { + if ((this.docblockBuilder == null)) { + this.docblockBuilder = new DocblockBuilder(); + } + + return this.docblockBuilder; + }, + + /** + * @return {FunctionBuilder} + */ + getRefactoringFunctionBuilder() { + if ((this.functionBuilder == null)) { + this.functionBuilder = new FunctionBuilder(); + } + + return this.functionBuilder; + }, + + /** + * @return {ParameterParser} + */ + getRefactoringParameterParser() { + if ((this.parameterParser == null)) { + this.parameterParser = new ParameterParser(this.getRefactoringTypeHelper()); + } + + return this.parameterParser; + }, + + /** + * @return {Builder} + */ + getRefactoringBuilder() { + if ((this.builder == null)) { + this.builder = new Builder( + this.getRefactoringParameterParser(), + this.getRefactoringDocblockBuilder(), + this.getRefactoringFunctionBuilder(), + this.getRefactoringTypeHelper() + ); + } + + return this.builder; + }, + + /** + * @return {Array} + */ + getRefactoringProviders() { + if ((this.refactoringProviders == null)) { + this.refactoringProviders = []; + this.refactoringProviders.push(new DocblockProvider(this.getRefactoringTypeHelper(), this.getRefactoringDocblockBuilder())); + this.refactoringProviders.push(new IntroducePropertyProvider(this.getRefactoringDocblockBuilder())); + this.refactoringProviders.push(new GetterSetterProvider(this.getRefactoringTypeHelper(), this.getRefactoringFunctionBuilder(), this.getRefactoringDocblockBuilder())); + this.refactoringProviders.push(new ExtractMethodProvider(this.getRefactoringBuilder())); + this.refactoringProviders.push(new ConstructorGenerationProvider(this.getRefactoringTypeHelper(), this.getRefactoringFunctionBuilder(), this.getRefactoringDocblockBuilder())); + + this.refactoringProviders.push(new OverrideMethodProvider(this.getRefactoringDocblockBuilder(), this.getRefactoringFunctionBuilder())); + this.refactoringProviders.push(new StubAbstractMethodProvider(this.getRefactoringDocblockBuilder(), this.getRefactoringFunctionBuilder())); + this.refactoringProviders.push(new StubInterfaceMethodProvider(this.getRefactoringDocblockBuilder(), this.getRefactoringFunctionBuilder())); + } + + return this.refactoringProviders; + } +}; diff --git a/lib/PhpInvoker.coffee b/lib/PhpInvoker.coffee deleted file mode 100644 index e6b48c94..00000000 --- a/lib/PhpInvoker.coffee +++ /dev/null @@ -1,169 +0,0 @@ -os = require "os" -child_process = require "child_process" - -module.exports = - -##* -# Invokes PHP. -## -class PhpInvoker - ###* - * Constructor. - * - * @param {Config} config - ### - constructor: (@config) -> - - ###* - * Invokes PHP. - * - * NOTE: The composer:1.6.4 image uses the Alpine version of the "PHP 7.x" image of PHP, which at the time of - # writing is PHP 7.2. The most important part is that the PHP version used for Composer installations is the same - # as the one used for actually running the server to avoid outdated or too recent dependencies. - * - * @param {Array} parameters - * @param {Array} additionalDockerRunParameters - * @param {Object} options - * @param {String} dockerImage - * - * @return {Process} - ### - invoke: (parameters, additionalDockerRunParameters = [], options = {}, dockerImage = 'composer:1.6.4') -> - executionType = @config.get('core.phpExecutionType') - - if executionType == 'host' - return child_process.spawn(@config.get('core.phpCommand'), parameters, options) - - command = 'docker' - dockerParameters = @getDockerRunParameters(dockerImage, additionalDockerRunParameters) - dockerParameters = dockerParameters.concat(parameters) - - if executionType == 'docker-polkit' - dockerParameters = [command].concat(dockerParameters) - command = 'pkexec' - - process = child_process.spawn(command, dockerParameters) - - console.debug(command, dockerParameters) - - # NOTE: Uncomment this to test failures - process.stdout.on 'data', (data) => - console.debug('STDOUT', data.toString()) - - process.stderr.on 'data', (data) => - console.debug('STDERR', data.toString()) - - return process - - ###* - * @param {Array} additionalDockerRunParameters - * - * @return {Array} - ### - getDockerRunParameters: (dockerImage, additionalDockerRunParameters) -> - parameters = ['run', '--rm=true'] - - for src, dest of @getPathsToMountInDockerContainer() - parameters.push('-v') - parameters.push(src + ':' + dest) - - return parameters.concat(additionalDockerRunParameters).concat([dockerImage, 'php']) - - ###* - * @return {Object} - ### - getPathsToMountInDockerContainer: () -> - paths = {} - paths[@config.get('storagePath')] = @config.get('storagePath') - - for path in @config.get('core.additionalDockerVolumes') - parts = path.split(':') - - paths[parts[0]] = parts[1] - - for path in atom.project.getPaths() - paths[path] = path - - return @normalizeVolumePaths(paths) - - ###* - * @param {Object} pathMap - * - * @return {Object} - ### - normalizeVolumePaths: (pathMap) -> - newPathMap = {} - - for src, dest of pathMap - newPathMap[@normalizeVolumePath(src)] = @normalizeVolumePath(dest) - - return newPathMap - - ###* - * @param {String} path - * - * @return {String} - ### - normalizeVolumePath: (path) -> - if os.platform() != 'win32' - return path - - matches = path.match(/^([A-Z]+):(.+)$/) - - # Path already normalized. - if matches != null - # On Windows, paths for Docker volumes are specified as - # /c/Path/To/Item. - path = '/' + matches[1].toLowerCase() + matches[2] - - return path.replace(/\\/g, '/') - - ###* - * @param {String} path - * - * @return {String} - ### - denormalizeVolumePath: (path) -> - if os.platform() != 'win32' - return path - - matches = path.match(/^\/([a-z]+)\/(.+)$/) - - # Path already denormalized. - if matches != null - # On Windows, paths for Docker volumes are specified as - # /c/Path/To/Item. - path = matches[1].toUpperCase() + ':\\' + matches[2] - - return path.replace(/\//g, '\\') - - ###* - * Retrieves a path normalized for the current platform *and* runtime. - * - * On Windows, we still need UNIX paths if we are using Docker as runtime, - * but not if we are using the host PHP. - * - * @param {String} path - * - * @return {String} - ### - normalizePlatformAndRuntimePath: (path) -> - if @config.get('core.phpExecutionType') == 'host' - return path - - return @normalizeVolumePath(path) - - ###* - * Retrieves a path denormalized for the current platform *and* runtime. - * - * On Windows, this converts Docker paths back to Windows paths. - * - * @param {String} path - * - * @return {String} - ### - denormalizePlatformAndRuntimePath: (path) -> - if @config.get('core.phpExecutionType') == 'host' - return path - - return @denormalizeVolumePath(path) diff --git a/lib/PhpInvoker.js b/lib/PhpInvoker.js new file mode 100644 index 00000000..3a110b62 --- /dev/null +++ b/lib/PhpInvoker.js @@ -0,0 +1,208 @@ +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let PhpInvoker; +const os = require("os"); +const child_process = require("child_process"); + +module.exports = + +//#* +// Invokes PHP. +//# +(PhpInvoker = class PhpInvoker { + /** + * Constructor. + * + * @param {Config} config + */ + constructor(config) { + this.config = config; + } + + /** + * Invokes PHP. + * + * NOTE: The composer:1.6.4 image uses the Alpine version of the "PHP 7.x" image of PHP, which at the time of + * writing is PHP 7.2. The most important part is that the PHP version used for Composer installations is the same + * as the one used for actually running the server to avoid outdated or too recent dependencies. + * + * @param {Array} parameters + * @param {Array} additionalDockerRunParameters + * @param {Object} options + * @param {String} dockerImage + * + * @return {Process} + */ + invoke(parameters, additionalDockerRunParameters, options, dockerImage) { + if (additionalDockerRunParameters == null) { additionalDockerRunParameters = []; } + if (options == null) { options = {}; } + if (dockerImage == null) { dockerImage = 'composer:1.6.4'; } + const executionType = this.config.get('core.phpExecutionType'); + + if (executionType === 'host') { + return child_process.spawn(this.config.get('core.phpCommand'), parameters, options); + } + + let command = 'docker'; + let dockerParameters = this.getDockerRunParameters(dockerImage, additionalDockerRunParameters); + dockerParameters = dockerParameters.concat(parameters); + + if (executionType === 'docker-polkit') { + dockerParameters = [command].concat(dockerParameters); + command = 'pkexec'; + } + + const process = child_process.spawn(command, dockerParameters); + + console.debug(command, dockerParameters); + + // NOTE: Uncomment this to test failures + process.stdout.on('data', data => { + return console.debug('STDOUT', data.toString()); + }); + + process.stderr.on('data', data => { + return console.debug('STDERR', data.toString()); + }); + + return process; + } + + /** + * @param {Array} additionalDockerRunParameters + * + * @return {Array} + */ + getDockerRunParameters(dockerImage, additionalDockerRunParameters) { + const parameters = ['run', '--rm=true']; + + const object = this.getPathsToMountInDockerContainer(); + for (let src in object) { + const dest = object[src]; + parameters.push('-v'); + parameters.push(src + ':' + dest); + } + + return parameters.concat(additionalDockerRunParameters).concat([dockerImage, 'php']); + } + + /** + * @return {Object} + */ + getPathsToMountInDockerContainer() { + const paths = {}; + paths[this.config.get('storagePath')] = this.config.get('storagePath'); + + for (var path of Array.from(this.config.get('core.additionalDockerVolumes'))) { + const parts = path.split(':'); + + paths[parts[0]] = parts[1]; + } + + for (path of Array.from(atom.project.getPaths())) { + paths[path] = path; + } + + return this.normalizeVolumePaths(paths); + } + + /** + * @param {Object} pathMap + * + * @return {Object} + */ + normalizeVolumePaths(pathMap) { + const newPathMap = {}; + + for (let src in pathMap) { + const dest = pathMap[src]; + newPathMap[this.normalizeVolumePath(src)] = this.normalizeVolumePath(dest); + } + + return newPathMap; + } + + /** + * @param {String} path + * + * @return {String} + */ + normalizeVolumePath(path) { + if (os.platform() !== 'win32') { + return path; + } + + const matches = path.match(/^([A-Z]+):(.+)$/); + + // Path already normalized. + if (matches !== null) { + // On Windows, paths for Docker volumes are specified as + // /c/Path/To/Item. + path = `/${matches[1].toLowerCase()}${matches[2]}`; + } + + return path.replace(/\\/g, '/'); + } + + /** + * @param {String} path + * + * @return {String} + */ + denormalizeVolumePath(path) { + if (os.platform() !== 'win32') { + return path; + } + + const matches = path.match(/^\/([a-z]+)\/(.+)$/); + + // Path already denormalized. + if (matches !== null) { + // On Windows, paths for Docker volumes are specified as + // /c/Path/To/Item. + path = matches[1].toUpperCase() + ':\\' + matches[2]; + } + + return path.replace(/\//g, '\\'); + } + + /** + * Retrieves a path normalized for the current platform *and* runtime. + * + * On Windows, we still need UNIX paths if we are using Docker as runtime, + * but not if we are using the host PHP. + * + * @param {String} path + * + * @return {String} + */ + normalizePlatformAndRuntimePath(path) { + if (this.config.get('core.phpExecutionType') === 'host') { + return path; + } + + return this.normalizeVolumePath(path); + } + + /** + * Retrieves a path denormalized for the current platform *and* runtime. + * + * On Windows, this converts Docker paths back to Windows paths. + * + * @param {String} path + * + * @return {String} + */ + denormalizePlatformAndRuntimePath(path) { + if (this.config.get('core.phpExecutionType') === 'host') { + return path; + } + + return this.denormalizeVolumePath(path); + } +}); diff --git a/lib/ProjectManager.coffee b/lib/ProjectManager.coffee deleted file mode 100644 index 59f595ec..00000000 --- a/lib/ProjectManager.coffee +++ /dev/null @@ -1,415 +0,0 @@ -{Directory} = require 'atom' - -path = require 'path' - -process = require 'process' - -module.exports = - -##* -# Handles project management -## -class ProjectManager - ###* - * @var {Object} - ### - proxy: null - - ###* - * @var {Object} - ### - indexingMediator: null - - ###* - * The service instance from the project-manager package. - * - * @var {Object|null} - ### - activeProject: null - - ###* - * Whether project indexing is currently happening. - * - * @var {bool} - ### - isProjectIndexingFlag: false - - ###* - * Keeps track of files that are being indexed. - * - * @var {Object} - ### - indexMap: null - - ###* - * Default settings for projects. - * - * Note that this object will be shared across instances! - * - * @var {Object} - ### - defaultProjectSettings: - enabled: true - serenata: - enabled: true - phpVersion: 7.2 - excludedPaths: [] - fileExtensions: ['php'] - - ###* - * @param {Object} proxy - * @param {Object} indexingMediator - ### - constructor: (@proxy, @indexingMediator) -> - @indexMap = {} - - ###* - * @return {Object|null} - ### - getActiveProject: () -> - return @activeProject - - ###* - * @return {bool} - ### - hasActiveProject: () -> - if @getActiveProject()? - return true - - return false - - ###* - * @return {bool} - ### - isProjectIndexing: () -> - return @isProjectIndexingFlag - - ###* - * Sets up the specified project for usage with this package. - * - * Default settings will be stored inside the package, if they aren't already present. If they already exist, they - * will not be overwritten. - * - * Note that this method does not explicitly request persisting settings from the external project manager service. - * - * @param {Object} project - * - * @return {Object} The new settings of the project (that could be persisted). - ### - setUpProject: (project) -> - projectPhpSettings = if project.getProps().php? then project.getProps().php else {} - - if projectPhpSettings.serenata? - throw new Error(''' - The currently active project was already initialized. To prevent existing settings from getting lost, - the request has been aborted. - ''') - - if not projectPhpSettings.enabled - projectPhpSettings.enabled = true - - if not projectPhpSettings.serenata? - projectPhpSettings.serenata = @defaultProjectSettings.serenata - - existingProps = project.getProps() - existingProps.php = projectPhpSettings - - return existingProps - - ###* - * @param {Object} project - ### - load: (project) -> - @activeProject = null - - return if project.getProps().php?.enabled != true - - projectSettings = @getProjectSettings(project) - - return if projectSettings?.enabled != true - - @validateProject(project) - - @activeProject = project - - @proxy.setIndexDatabaseName(@getIndexDatabaseName(project)) - - successHandler = (repository) => - return if not repository? - return if not repository.async? - - # Will trigger on things such as git checkout. - repository.async.onDidChangeStatuses () => - @attemptIndex(project) - - failureHandler = () => - return - - for projectDirectory in @getProjectPaths(project) - projectDirectoryObject = new Directory(projectDirectory) - - atom.project.repositoryForDirectory(projectDirectoryObject).then(successHandler, failureHandler) - - ###* - * @param {Object} - * - * @return {String} - ### - getIndexDatabaseName: (project) -> - return project.getProps().title - - ###* - * Validates a project by validating its settings. - * - * Throws an Error if something is not right with the project. - * - * @param {Object} project - ### - validateProject: (project) -> - projectSettings = @getProjectSettings(project) - - if not projectSettings? - throw new Error( - 'No project settings were found under a node called "php.serenata" in your project settings' - ) - - phpVersion = projectSettings.phpVersion - - if isNaN(parseFloat(phpVersion)) or not isFinite(phpVersion) - throw new Error(''' - PHP version in your project settings must be a number such as 7.2 - ''') - - ###* - * Retrieves a list of file extensions to include in indexing. - * - * @param {Object} project - * - * @return {Array} - ### - getFileExtensionsToIndex: (project) -> - projectPaths = @getProjectPaths(project) - projectSettings = @getProjectSettings(project) - - fileExtensions = projectSettings?.fileExtensions - - if not fileExtensions? - fileExtensions = [] - - return fileExtensions - - ###* - * Retrieves a list of absolute paths to exclude from indexing. - * - * @param {Object} project - * - * @return {Array} - ### - getAbsoluteExcludedPaths: (project) -> - projectPaths = @getProjectPaths(project) - projectSettings = @getProjectSettings(project) - - excludedPaths = projectSettings?.excludedPaths - - if not excludedPaths? - excludedPaths = [] - - absoluteExcludedPaths = [] - - for excludedPath in excludedPaths - if path.isAbsolute(excludedPath) - absoluteExcludedPaths.push(excludedPath) - - else - matches = excludedPath.match(/^\{(\d+)\}(\/.*)$/) - - if matches? - index = matches[1] - - # Relative paths starting with {n} are relative to the project path at index {n}, e.g. "{0}/test". - if index > projectPaths.length - throw new Error("Requested project path index " + index + ", but the project does not have that many paths!") - - absoluteExcludedPaths.push(projectPaths[index] + matches[2]) - - else - absoluteExcludedPaths.push(path.normalize(excludedPath)) - - return absoluteExcludedPaths - - ###* - * Indexes the project asynchronously. - * - * @param {Object} project - * - * @return {Promise} - ### - performIndex: (project) -> - successHandler = () => - return @indexingMediator.reindex( - @getProjectPaths(project), - null, - @getAbsoluteExcludedPaths(project), - @getFileExtensionsToIndex(project) - ) - - return @indexingMediator.vacuum().then(successHandler) - - ###* - * Performs a project index, but only if one is not currently already happening. - * - * @param {Object} project - * - * @return {Promise|null} - ### - attemptIndex: (project) -> - return null if @isProjectIndexing() - - @isProjectIndexingFlag = true - - handler = () => - @isProjectIndexingFlag = false - - successHandler = handler - failureHandler = handler - - return @performIndex(project).then(successHandler, failureHandler) - - ###* - * Indexes the current project, but only if one is not currently already happening. - * - * @return {Promise} - ### - attemptCurrentProjectIndex: () -> - return @attemptIndex(@getActiveProject()) - - ###* - * Initializes the project. - * - * @return {Promise|null} - ### - initializeCurrentProject: () -> - return @indexingMediator.initialize() - - ###* - * Vacuums the project. - * - * @return {Promise|null} - ### - vacuumCurrentProject: () -> - return @indexingMediator.vacuum() - - ###* - * Indexes a file asynchronously. - * - * @param {Object} project - * @param {String} fileName The file to index. - * @param {String|null} source The source code of the file to index. - * - * @return {CancellablePromise} - ### - performFileIndex: (project, fileName, source = null) -> - return @indexingMediator.reindex( - fileName, - source, - @getAbsoluteExcludedPaths(project), - @getFileExtensionsToIndex(project) - ) - - ###* - * Performs a file index. - * - * @param {Object} project - * @param {String} fileName The file to index. - * @param {String|null} source The source code of the file to index. - * - * @return {Promise|null} - ### - attemptFileIndex: (project, fileName, source = null) -> - if fileName of @indexMap - @indexMap[fileName].cancel() - - @indexMap[fileName] = @performFileIndex(project, fileName, source) - - onIndexFinish = () => - delete @indexMap[fileName] - - return @indexMap[fileName].then(onIndexFinish, onIndexFinish) - - ###* - * Indexes the current project asynchronously. - * - * @param {String} fileName The file to index. - * @param {String|null} source The source code of the file to index. - * - * @return {Promise} - ### - attemptCurrentProjectFileIndex: (fileName, source = null) -> - return @attemptFileIndex(@getActiveProject(), fileName, source) - - ###* - * @return {Object|null} - ### - getProjectSettings: (project) -> - if project.getProps().php?.serenata? - return project.getProps().php.serenata - - # Legacy name supported for backwards compatibility. - else if project.getProps().php?.php_integrator? - return project.getProps().php.php_integrator - - return null - - ###* - * @return {Object|null} - ### - getCurrentProjectSettings: () -> - return @getProjectSettings(@getActiveProject()) - - ###* - * @return {Array} - ### - getProjectPaths: (project) -> - return project.getProps().paths - - ###* - * Indicates if the specified file is part of the project. - * - * @param {Object} project - * @param {String} fileName - * - * @return {bool} - ### - isFilePartOfProject: (project, fileName) -> - for projectDirectory in @getProjectPaths(project) - projectDirectoryObject = new Directory(projectDirectory) - - # #295 - Resolve home folders. The core supports this out of the box, but we still need to limit files to - # the workspace here. Atom is picky about this: if the project contains a tilde, the contains method below - # will only return true if the file path also contains the tilde, which is not the case by default for - # editor file paths. - if projectDirectory.startsWith('~') - fileName = fileName.replace(@getHomeFolderPath(), '~') - - if projectDirectoryObject.contains(fileName) - return true - - return false - - ###* - * @return {String} - ### - getHomeFolderPath: () -> - homeFolderVarName = if process.platform == 'win32' then 'USERPROFILE' else 'HOME' - - return process.env[homeFolderVarName]; - - ###* - * Indicates if the specified file is part of the current project. - * - * @param {String} fileName - * - * @return {bool} - ### - isFilePartOfCurrentProject: (fileName) -> - return @isFilePartOfProject(@getActiveProject(), fileName) diff --git a/lib/ProjectManager.js b/lib/ProjectManager.js new file mode 100644 index 00000000..2ff74232 --- /dev/null +++ b/lib/ProjectManager.js @@ -0,0 +1,492 @@ +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS103: Rewrite code to no longer use __guard__ + * DS205: Consider reworking code to avoid use of IIFEs + * DS206: Consider reworking classes to avoid initClass + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let ProjectManager; +const {Directory} = require('atom'); + +const path = require('path'); + +const process = require('process'); + +module.exports = + +//#* +// Handles project management +//# +(ProjectManager = (function() { + ProjectManager = class ProjectManager { + static initClass() { + /** + * @var {Object} + */ + this.prototype.proxy = null; + + /** + * @var {Object} + */ + this.prototype.indexingMediator = null; + + /** + * The service instance from the project-manager package. + * + * @var {Object|null} + */ + this.prototype.activeProject = null; + + /** + * Whether project indexing is currently happening. + * + * @var {bool} + */ + this.prototype.isProjectIndexingFlag = false; + + /** + * Keeps track of files that are being indexed. + * + * @var {Object} + */ + this.prototype.indexMap = null; + + /** + * Default settings for projects. + * + * Note that this object will be shared across instances! + * + * @var {Object} + */ + this.prototype.defaultProjectSettings = { + enabled: true, + serenata: { + enabled: true, + phpVersion: 7.2, + excludedPaths: [], + fileExtensions: ['php'] + } + }; + } + + /** + * @param {Object} proxy + * @param {Object} indexingMediator + */ + constructor(proxy, indexingMediator) { + this.proxy = proxy; + this.indexingMediator = indexingMediator; + this.indexMap = {}; + } + + /** + * @return {Object|null} + */ + getActiveProject() { + return this.activeProject; + } + + /** + * @return {bool} + */ + hasActiveProject() { + if (this.getActiveProject() != null) { + return true; + } + + return false; + } + + /** + * @return {bool} + */ + isProjectIndexing() { + return this.isProjectIndexingFlag; + } + + /** + * Sets up the specified project for usage with this package. + * + * Default settings will be stored inside the package, if they aren't already present. If they already exist, they + * will not be overwritten. + * + * Note that this method does not explicitly request persisting settings from the external project manager service. + * + * @param {Object} project + * + * @return {Object} The new settings of the project (that could be persisted). + */ + setUpProject(project) { + const projectPhpSettings = (project.getProps().php != null) ? project.getProps().php : {}; + + if (projectPhpSettings.serenata != null) { + throw new Error(`\ +The currently active project was already initialized. To prevent existing settings from getting lost, +the request has been aborted.\ +`); + } + + if (!projectPhpSettings.enabled) { + projectPhpSettings.enabled = true; + } + + if ((projectPhpSettings.serenata == null)) { + projectPhpSettings.serenata = this.defaultProjectSettings.serenata; + } + + const existingProps = project.getProps(); + existingProps.php = projectPhpSettings; + + return existingProps; + } + + /** + * @param {Object} project + */ + load(project) { + this.activeProject = null; + + if (__guard__(project.getProps().php, x => x.enabled) !== true) { return; } + + const projectSettings = this.getProjectSettings(project); + + if ((projectSettings != null ? projectSettings.enabled : undefined) !== true) { return; } + + this.validateProject(project); + + this.activeProject = project; + + this.proxy.setIndexDatabaseName(this.getIndexDatabaseName(project)); + + const successHandler = repository => { + if ((repository == null)) { return; } + if ((repository.async == null)) { return; } + + // Will trigger on things such as git checkout. + return repository.async.onDidChangeStatuses(() => { + return this.attemptIndex(project); + }); + }; + + const failureHandler = () => { + }; + + return (() => { + const result = []; + for (let projectDirectory of Array.from(this.getProjectPaths(project))) { + const projectDirectoryObject = new Directory(projectDirectory); + + result.push(atom.project.repositoryForDirectory(projectDirectoryObject).then(successHandler, failureHandler)); + } + return result; + })(); + } + + /** + * @param {Object} + * + * @return {String} + */ + getIndexDatabaseName(project) { + return project.getProps().title; + } + + /** + * Validates a project by validating its settings. + * + * Throws an Error if something is not right with the project. + * + * @param {Object} project + */ + validateProject(project) { + const projectSettings = this.getProjectSettings(project); + + if ((projectSettings == null)) { + throw new Error( + 'No project settings were found under a node called "php.serenata" in your project settings' + ); + } + + const { phpVersion } = projectSettings; + + if (isNaN(parseFloat(phpVersion)) || !isFinite(phpVersion)) { + throw new Error(`\ +PHP version in your project settings must be a number such as 7.2\ +`); + } + } + + /** + * Retrieves a list of file extensions to include in indexing. + * + * @param {Object} project + * + * @return {Array} + */ + getFileExtensionsToIndex(project) { + const projectPaths = this.getProjectPaths(project); + const projectSettings = this.getProjectSettings(project); + + let fileExtensions = projectSettings != null ? projectSettings.fileExtensions : undefined; + + if ((fileExtensions == null)) { + fileExtensions = []; + } + + return fileExtensions; + } + + /** + * Retrieves a list of absolute paths to exclude from indexing. + * + * @param {Object} project + * + * @return {Array} + */ + getAbsoluteExcludedPaths(project) { + const projectPaths = this.getProjectPaths(project); + const projectSettings = this.getProjectSettings(project); + + let excludedPaths = projectSettings != null ? projectSettings.excludedPaths : undefined; + + if ((excludedPaths == null)) { + excludedPaths = []; + } + + const absoluteExcludedPaths = []; + + for (let excludedPath of Array.from(excludedPaths)) { + if (path.isAbsolute(excludedPath)) { + absoluteExcludedPaths.push(excludedPath); + + } else { + const matches = excludedPath.match(/^\{(\d+)\}(\/.*)$/); + + if (matches != null) { + const index = matches[1]; + + // Relative paths starting with {n} are relative to the project path at index {n}, e.g. "{0}/test". + if (index > projectPaths.length) { + throw new Error(`Requested project path index ${index}, but the project does not have that many paths!`); + } + + absoluteExcludedPaths.push(projectPaths[index] + matches[2]); + + } else { + absoluteExcludedPaths.push(path.normalize(excludedPath)); + } + } + } + + return absoluteExcludedPaths; + } + + /** + * Indexes the project asynchronously. + * + * @param {Object} project + * + * @return {Promise} + */ + performIndex(project) { + const successHandler = () => { + return this.indexingMediator.reindex( + this.getProjectPaths(project), + null, + this.getAbsoluteExcludedPaths(project), + this.getFileExtensionsToIndex(project) + ); + }; + + return this.indexingMediator.vacuum().then(successHandler); + } + + /** + * Performs a project index, but only if one is not currently already happening. + * + * @param {Object} project + * + * @return {Promise|null} + */ + attemptIndex(project) { + if (this.isProjectIndexing()) { return null; } + + this.isProjectIndexingFlag = true; + + const handler = () => { + return this.isProjectIndexingFlag = false; + }; + + const successHandler = handler; + const failureHandler = handler; + + return this.performIndex(project).then(successHandler, failureHandler); + } + + /** + * Indexes the current project, but only if one is not currently already happening. + * + * @return {Promise} + */ + attemptCurrentProjectIndex() { + return this.attemptIndex(this.getActiveProject()); + } + + /** + * Initializes the project. + * + * @return {Promise|null} + */ + initializeCurrentProject() { + return this.indexingMediator.initialize(); + } + + /** + * Vacuums the project. + * + * @return {Promise|null} + */ + vacuumCurrentProject() { + return this.indexingMediator.vacuum(); + } + + /** + * Indexes a file asynchronously. + * + * @param {Object} project + * @param {String} fileName The file to index. + * @param {String|null} source The source code of the file to index. + * + * @return {CancellablePromise} + */ + performFileIndex(project, fileName, source = null) { + return this.indexingMediator.reindex( + fileName, + source, + this.getAbsoluteExcludedPaths(project), + this.getFileExtensionsToIndex(project) + ); + } + + /** + * Performs a file index. + * + * @param {Object} project + * @param {String} fileName The file to index. + * @param {String|null} source The source code of the file to index. + * + * @return {Promise|null} + */ + attemptFileIndex(project, fileName, source = null) { + if (fileName in this.indexMap) { + this.indexMap[fileName].cancel(); + } + + this.indexMap[fileName] = this.performFileIndex(project, fileName, source); + + const onIndexFinish = () => { + return delete this.indexMap[fileName]; + }; + + return this.indexMap[fileName].then(onIndexFinish, onIndexFinish); + } + + /** + * Indexes the current project asynchronously. + * + * @param {String} fileName The file to index. + * @param {String|null} source The source code of the file to index. + * + * @return {Promise} + */ + attemptCurrentProjectFileIndex(fileName, source = null) { + return this.attemptFileIndex(this.getActiveProject(), fileName, source); + } + + /** + * @return {Object|null} + */ + getProjectSettings(project) { + if (__guard__(project.getProps().php, x => x.serenata) != null) { + return project.getProps().php.serenata; + + // Legacy name supported for backwards compatibility. + } else if (__guard__(project.getProps().php, x1 => x1.php_integrator) != null) { + return project.getProps().php.php_integrator; + } + + return null; + } + + /** + * @return {Object|null} + */ + getCurrentProjectSettings() { + return this.getProjectSettings(this.getActiveProject()); + } + + /** + * @return {Array} + */ + getProjectPaths(project) { + return project.getProps().paths; + } + + /** + * Indicates if the specified file is part of the project. + * + * @param {Object} project + * @param {String} fileName + * + * @return {bool} + */ + isFilePartOfProject(project, fileName) { + for (let projectDirectory of Array.from(this.getProjectPaths(project))) { + const projectDirectoryObject = new Directory(projectDirectory); + + // #295 - Resolve home folders. The core supports this out of the box, but we still need to limit files to + // the workspace here. Atom is picky about this: if the project contains a tilde, the contains method below + // will only return true if the file path also contains the tilde, which is not the case by default for + // editor file paths. + if (projectDirectory.startsWith('~')) { + fileName = fileName.replace(this.getHomeFolderPath(), '~'); + } + + if (projectDirectoryObject.contains(fileName)) { + return true; + } + } + + return false; + } + + /** + * @return {String} + */ + getHomeFolderPath() { + const homeFolderVarName = process.platform === 'win32' ? 'USERPROFILE' : 'HOME'; + + return process.env[homeFolderVarName]; + } + + /** + * Indicates if the specified file is part of the current project. + * + * @param {String} fileName + * + * @return {bool} + */ + isFilePartOfCurrentProject(fileName) { + return this.isFilePartOfProject(this.getActiveProject(), fileName); + } + }; + ProjectManager.initClass(); + return ProjectManager; +})()); + +function __guard__(value, transform) { + return (typeof value !== 'undefined' && value !== null) ? transform(value) : undefined; +} \ No newline at end of file diff --git a/lib/Proxy.coffee b/lib/Proxy.coffee deleted file mode 100644 index 7f58753d..00000000 --- a/lib/Proxy.coffee +++ /dev/null @@ -1,1141 +0,0 @@ -fs = require 'fs' -net = require 'net' -path = require 'path' -mkdirp = require 'mkdirp' -stream = require 'stream' -child_process = require 'child_process' -sanitize = require 'sanitize-filename' - -CancellablePromise = require './CancellablePromise' - -module.exports = - -##* -# Proxy that handles communicating with the PHP side. -## -class Proxy - ###* - * The config to use. - * - * @var {Object} - ### - config: null - - ###* - * @var {Object} - ### - phpInvoker: null - - ###* - * The name (without path or extension) of the database file to use. - * - * @var {Object} - ### - indexDatabaseName: null - - ###* - * @var {Boolean} - ### - isActive: false - - ###* - * @var {String} - ### - corePath: null - - ###* - * @var {Object} - ### - phpServer: null - - ###* - * @var {CancellablePromise} - ### - phpServerPromise: null - - ###* - * @var {Object} - ### - client: null - - ###* - * @var {Object} - ### - requestQueue: null - - ###* - * @var {Number} - ### - nextRequestId: 1 - - ###* - * @var {Object} - ### - response: null - - ###* - * @var {String} - ### - HEADER_DELIMITER: "\r\n" - - ###* - * @var {Number} - ### - FATAL_SERVER_ERROR: -32000 - - ###* - * Constructor. - * - * @param {Config} config - * @param {PhpInvoker} phpInvoker - ### - constructor: (@config, @phpInvoker) -> - @requestQueue = {} - @port = @getRandomServerPort() - - @resetResponseState() - - ###* - * Spawns the PHP socket server process. - * - * @param {Number} port - * - * @return {Promise} - ### - spawnPhpServer: (port) -> - memoryLimit = @config.get('core.memoryLimit') - socketHost = if @config.get('core.phpExecutionType') == 'host' then '127.0.0.1' else '0.0.0.0' - - parameters = [ - # Enable these to profile (requires xdebug be installed). - #'-d zend_extension=/usr/lib/php/modules/xdebug.so' - #'-d xdebug.profiler_enable=On', - #'-d xdebug.profiler_output_dir=/tmp', - - '-d memory_limit=' + memoryLimit + 'M', - @phpInvoker.normalizePlatformAndRuntimePath(@corePath) + "/src/Main.php", - '--uri=tcp://' + socketHost + ':' + port - ] - - additionalDockerRunParameters = [ - '-p', '127.0.0.1:' + port + ':' + port - ] - - process = @phpInvoker.invoke(parameters, additionalDockerRunParameters) - - return new Promise (resolve, reject) => - process.stdout.on 'data', (data) => - message = data.toString() - - console.debug('The PHP server has something to say:', message) - - if message.startsWith('Starting socket server') - # Assume the server has successfully spawned the moment it says its first words. - resolve(process) - - process.stderr.on 'data', (data) => - console.warn('The PHP server has errors to report:', data.toString()) - - process.on 'error', (error) => - console.error('An error ocurred whilst invoking PHP', error) - reject() - - process.on 'close', (code) => - if code == 2 - console.error('Port ' + port + ' is already taken') - return - - else if code != 0 - detail = - "Serenata unexpectedly closed. Either something caused the process to stop, it crashed, or " + - "the socket closed. In case of the first two, you should see additional output indicating " + - "this is the case and you can report a bug. If there is no additional output, the server may " + - "have run out of memory (you can increase it via the settings screen)." - - console.error(detail) - - @closeServerConnection() - - @phpServer = null - reject() - - ###* - * @return {Number} - ### - getRandomServerPort: () -> - minPort = 10000 - maxPort = 40000 - - return Math.floor(Math.random() * (maxPort - minPort) + minPort) - - ###* - * Spawns the PHP socket server process. - * - * @param {Number} port - * - * @return {Promise} - ### - spawnPhpServerIfNecessary: (port) -> - if @phpServer - @phpServerPromise = null - - return new Promise (resolve, reject) => - resolve(@phpServer) - - else if @phpServerPromise - return @phpServerPromise - - successHandler = (phpServer) => - @phpServer = phpServer - - return phpServer - - failureHandler = () => - @phpServerPromise = null - - @phpServerPromise = @spawnPhpServer(port).then(successHandler, failureHandler) - - return @phpServerPromise - - ###* - * Closes the socket connection to the server. - ### - closeServerConnection: () -> - @rejectAllOpenRequests() - - return if not @client - - @client.destroy() - @client = null - - @resetResponseState() - - ###* - * Rejects all currently open requests. - ### - rejectAllOpenRequests: () -> - for id,request of @requestQueue - request.promise.reject('Socket connection encountered invalid state or was closed, please resend request') - - @requestQueue = {} - - ###* - * @return {Promise} - ### - getSocketConnection: () -> - return new Promise (resolve, reject) => - @spawnPhpServerIfNecessary(@port).then () => - if @client? - resolve(@client) - return - - @client = net.createConnection {port: @port}, () => - resolve(@client) - - @client.setNoDelay(true) - @client.on('error', @onSocketError.bind(this)) - @client.on('data', @onDataReceived.bind(this)) - @client.on('close', @onConnectionClosed.bind(this)) - - ###* - * @param {String} data - ### - onDataReceived: (data) -> - try - @processDataBuffer(data) - - catch error - console.warn('Encountered some invalid data, resetting state. Error: ', error) - - @resetResponseState() - - ###* - * @param {Object} error - ### - onSocketError: (error) -> - # Do nothing here, this should silence socket errors such as ECONNRESET. After this is called, the socket will - # be closed and all handling is performed there. - console.warn('The socket connection notified us of an error', error) - - ###* - * @param {Boolean} hadError - ### - onConnectionClosed: (hadError) -> - @closeServerConnection() - - ###* - * @param {Buffer} dataBuffer - ### - processDataBuffer: (dataBuffer) -> - if not @response.length? - contentLengthHeader = @readRawHeader(dataBuffer) - @response.length = @getLengthFromContentLengthHeader(contentLengthHeader) - - bytesRead = contentLengthHeader.length + @HEADER_DELIMITER.length - - else if not @response.wasBoundaryFound - header = @readRawHeader(dataBuffer) - - if header.length == 0 - @response.wasBoundaryFound = true - - bytesRead = header.length + @HEADER_DELIMITER.length - - else - bytesRead = Math.min(dataBuffer.length, @response.length - @response.bytesRead) - - @response.content = Buffer.concat([@response.content, dataBuffer.slice(0, bytesRead)]) - @response.bytesRead += bytesRead - - if @response.bytesRead == @response.length - jsonRpcResponse = @getJsonRpcResponseFromResponseBuffer(@response.content) - - @processJsonRpcResponse(jsonRpcResponse) - - @resetResponseState() - - dataBuffer = dataBuffer.slice(bytesRead) - - if dataBuffer.length > 0 - @processDataBuffer(dataBuffer) - - ###* - * @param {Object} jsonRpcResponse - ### - processJsonRpcResponse: (jsonRpcResponse) -> - if jsonRpcResponse.id? - jsonRpcRequest = @requestQueue[jsonRpcResponse.id] - - if not jsonRpcRequest? - console.warn('Received response for request that was already removed from the queue', jsonRpcResponse) - return - - @processJsonRpcResponseForRequest(jsonRpcResponse, jsonRpcRequest) - - delete @requestQueue[jsonRpcResponse.id] - - else - @processNotificationJsonRpcResponse(jsonRpcResponse) - - ###* - * @param {Object} jsonRpcResponse - * @param {Object} jsonRpcRequest - ### - processJsonRpcResponseForRequest: (jsonRpcResponse, jsonRpcRequest) -> - if jsonRpcResponse.error? - jsonRpcRequest.promise.reject({ - request : jsonRpcRequest - response : jsonRpcResponse - error : jsonRpcResponse.error - }) - - if jsonRpcResponse.error.code == @FATAL_SERVER_ERROR - @showFatalServerError(jsonRpcResponse.error) - - else - jsonRpcRequest.promise.resolve(jsonRpcResponse.result) - - ###* - * @param {Object} jsonRpcResponse - * @param {Object} jsonRpcRequest - ### - processNotificationJsonRpcResponse: (jsonRpcResponse) -> - if not jsonRpcResponse.result? - console.warn('Received a server notification without a result', jsonRpcResponse) - return - - if jsonRpcResponse.result.type == 'reindexProgressInformation' - if not jsonRpcResponse.result.requestId? - console.warn('Received progress information without a request ID to go with it', jsonRpcResponse) - return - - relatedJsonRpcRequest = @requestQueue[jsonRpcResponse.result.requestId] - - if not relatedJsonRpcRequest? - console.warn( - 'Received progress information for request that doesn\'t exist or was already finished', - jsonRpcResponse - ) - return - - else if not relatedJsonRpcRequest.streamCallback? - console.warn('Received progress information for a request that isn\'t interested in it', jsonRpcResponse) - return - - relatedJsonRpcRequest.streamCallback(jsonRpcResponse.result.progress) - - else - console.warn('Received a server notification with an unknown type', jsonRpcResponse) - - ###* - * @param {Buffer} dataBuffer - * - * @return {Object} - ### - getJsonRpcResponseFromResponseBuffer: (dataBuffer) -> - jsonRpcResponseString = dataBuffer.toString() - - return @getJsonRpcResponseFromResponseContent(jsonRpcResponseString) - - ###* - * @param {String} content - * - * @return {Object} - ### - getJsonRpcResponseFromResponseContent: (content) -> - return JSON.parse(content) - - ###* - * @param {Buffer} dataBuffer - * - * @throws {Error} - * - * @return {String} - ### - readRawHeader: (dataBuffer) -> - end = dataBuffer.indexOf(@HEADER_DELIMITER) - - if end == -1 - throw new Error('Header delimiter not found'); - - return dataBuffer.slice(0, end).toString() - - ###* - * @param {String} rawHeader - * - * @throws {Error} - * - * @return {Number} - ### - getLengthFromContentLengthHeader: (rawHeader) -> - parts = rawHeader.split(':') - - if parts.length != 2 - throw new Error('Unexpected amount of header parts found') - - contentLength = parseInt(parts[1]) - - if not contentLength? - throw new Error('Content length header does not have an integer as value') - - return contentLength - - ###* - * Resets the current response's state. - ### - resetResponseState: () -> - @response = - length : null - wasBoundaryFound : false - bytesRead : 0 - content : new Buffer([]) - - ###* - * Performs an asynchronous request to the PHP side. - * - * @param {Number} id - * @param {String} method - * @param {Object} parameters - * @param {Callback} streamCallback - * - * @return {CancellablePromise} - ### - performJsonRpcRequest: (id, method, parameters, streamCallback = null) -> - executor = (resolve, reject) => - if not @getIsActive() - reject('The proxy is not yet active, the core may be in the process of being downloaded') - return - - jsonRpcRequest = - jsonrpc : 2.0 - id : id - method : method - params : parameters - - @requestQueue[id] = { - id : id - streamCallback : streamCallback - request : jsonRpcRequest - - promise: { - resolve : resolve - reject : reject - } - } - - content = @getContentForJsonRpcRequest(jsonRpcRequest) - - @writeRawRequest(content) - - cancelHandler = () => - @performRequest('cancelRequest', {id : id}) - - return new CancellablePromise(executor, cancelHandler) - - ###* - * @param {Object} request - * - * @return {String} - ### - getContentForJsonRpcRequest: (request) -> - return JSON.stringify(request) - - ###* - * Writes a raw request to the connection. - * - * This may not happen immediately if the connection is not available yet. In that case, the request will be - * dispatched as soon as the connection becomes available. - * - * @param {String} content The content (body) of the request. - ### - writeRawRequest: (content) -> - @getSocketConnection().then (connection) => - lengthInBytes = (new TextEncoder('utf-8').encode(content)).length - - connection.write("Content-Length: " + lengthInBytes + @HEADER_DELIMITER) - connection.write(@HEADER_DELIMITER); - connection.write(content) - - ###* - * @param {Object} error - ### - showFatalServerError: (error) -> - detail = - "You've likely hit a snag in the Serenata server. Feel free to report it on its bug tracker. " + - "If you do, please include the information printed below.\n \n" + - - "Please do *not* report this to the issue tracker of this package on GitHub as it is not a bug here.\n \n" + - - "The server will attempt to restart itself.\n \n" + - - error.data.backtrace - - notification = atom.notifications.addError('Serenata - Darn, we\'ve crashed!', { - dismissable : true - detail : detail - - buttons: [ - { - text: 'Open issue tracker' - onDidClick: () -> - {shell} = require 'electron' - shell.openExternal('https://gitlab.com/Serenata/Serenata/issues') - }, - - { - text: 'Dismiss' - onDidClick: () -> - notification.dismiss() - } - ] - }) - - ###* - * @param {String} method - * @param {Object} parameters - * @param {Callback} streamCallback A method to invoke each time streaming data is received. - * @param {String|null} stdinData The data to pass to STDIN. - * - * @return {CancellablePromise} - ### - performRequest: (method, parameters, streamCallback = null, stdinData = null) -> - if stdinData? - parameters.stdin = true - parameters.stdinData = stdinData - - requestId = @nextRequestId++ - - return @performJsonRpcRequest(requestId, method, parameters, streamCallback) - - ###* - * Retrieves a list of available classes. - * - * @return {Promise} - ### - getClassList: () -> - if not @getIndexDatabasePath()? - return new CancellablePromise (resolve, reject) -> - reject('Request aborted as there is no project active (yet)') - - parameters = { - database : @getIndexDatabasePath() - } - - return @performRequest('classList', parameters) - - ###* - * Retrieves a list of available classes in the specified file. - * - * @param {String} file - * - * @return {Promise} - ### - getClassListForFile: (file) -> - if not file - return new CancellablePromise (resolve, reject) -> - reject('No file passed!') - - if not @getIndexDatabasePath()? - return new CancellablePromise (resolve, reject) -> - reject('Request aborted as there is no project active (yet)') - - parameters = { - database : @getIndexDatabasePath() - file : @phpInvoker.normalizePlatformAndRuntimePath(file) - } - - return @performRequest('classList', parameters) - - ###* - * Retrieves a list of namespaces. - * - * @return {Promise} - ### - getNamespaceList: () -> - if not @getIndexDatabasePath()? - return new CancellablePromise (resolve, reject) -> - reject('Request aborted as there is no project active (yet)') - - parameters = { - database : @getIndexDatabasePath() - } - - return @performRequest('namespaceList', parameters) - - ###* - * Retrieves a list of namespaces in the specified file. - * - * @param {String} file - * - * @return {Promise} - ### - getNamespaceListForFile: (file) -> - if not file - return new CancellablePromise (resolve, reject) -> - reject('No file passed!') - - if not @getIndexDatabasePath()? - return new CancellablePromise (resolve, reject) -> - reject('Request aborted as there is no project active (yet)') - - parameters = { - database : @getIndexDatabasePath() - file : @phpInvoker.normalizePlatformAndRuntimePath(file) - } - - return @performRequest('namespaceList', parameters) - - ###* - * Retrieves a list of available global constants. - * - * @return {Promise} - ### - getGlobalConstants: () -> - if not @getIndexDatabasePath()? - return new CancellablePromise (resolve, reject) -> - reject('Request aborted as there is no project active (yet)') - - parameters = { - database : @getIndexDatabasePath() - } - - return @performRequest('globalConstants', parameters) - - ###* - * Retrieves a list of available global functions. - * - * @return {Promise} - ### - getGlobalFunctions: () -> - if not @getIndexDatabasePath()? - return new CancellablePromise (resolve, reject) -> - reject('Request aborted as there is no project active (yet)') - - parameters = { - database : @getIndexDatabasePath() - } - - return @performRequest('globalFunctions', parameters) - - ###* - * Retrieves a list of available members of the class (or interface, trait, ...) with the specified name. - * - * @param {String} className - * - * @return {Promise} - ### - getClassInfo: (className) -> - if not className - return new CancellablePromise (resolve, reject) -> - reject('No class name passed!') - - if not @getIndexDatabasePath()? - return new CancellablePromise (resolve, reject) -> - reject('Request aborted as there is no project active (yet)') - - parameters = { - database : @getIndexDatabasePath() - name : className - } - - return @performRequest('classInfo', parameters) - - ###* - * Resolves a local type in the specified file, based on use statements and the namespace. - * - * @param {String} file - * @param {Number} line The line the type is located at. The first line is 1, not 0. - * @param {String} type - * @param {String} kind The kind of element. Either 'classlike', 'constant' or 'function'. - * - * @return {Promise} - ### - resolveType: (file, line, type, kind = 'classlike') -> - if not file - return new CancellablePromise (resolve, reject) -> - reject('No file passed!') - - if not line - return new CancellablePromise (resolve, reject) -> - reject('No line passed!') - - if not type - return new CancellablePromise (resolve, reject) -> - reject('No type passed!') - - if not kind - return new CancellablePromise (resolve, reject) -> - reject('No kind passed!') - - if not @getIndexDatabasePath()? - return new CancellablePromise (resolve, reject) -> - reject('Request aborted as there is no project active (yet)') - - parameters = { - database : @getIndexDatabasePath() - file : @phpInvoker.normalizePlatformAndRuntimePath(file) - line : line - type : type - kind : kind - } - - return @performRequest('resolveType', parameters) - - ###* - * Localizes a type to the specified file, making it relative to local use statements, if possible. If not possible, - * null is returned. - * - * @param {String} file - * @param {Number} line The line the type is located at. The first line is 1, not 0. - * @param {String} type - * @param {String} kind The kind of element. Either 'classlike', 'constant' or 'function'. - * - * @return {Promise} - ### - localizeType: (file, line, type, kind = 'classlike') -> - if not file - return new CancellablePromise (resolve, reject) -> - reject('No file passed!') - - if not line - return new CancellablePromise (resolve, reject) -> - reject('No line passed!') - - if not type - return new CancellablePromise (resolve, reject) -> - reject('No type passed!') - - if not kind - return new CancellablePromise (resolve, reject) -> - reject('No kind passed!') - - if not @getIndexDatabasePath()? - return new CancellablePromise (resolve, reject) -> - reject('Request aborted as there is no project active (yet)') - - parameters = { - database : @getIndexDatabasePath() - file : @phpInvoker.normalizePlatformAndRuntimePath(file) - line : line - type : type - kind : kind - } - - return @performRequest('localizeType', parameters) - - ###* - * Lints the specified file. - * - * @param {String} file - * @param {String|null} source The source code of the file to index. May be null if a directory is passed instead. - * @param {Object} options Additional options to set. Boolean properties noUnknownClasses, noUnknownMembers, - * noUnknownGlobalFunctions, noUnknownGlobalConstants, noDocblockCorrectness, - * noUnusedUseStatements and noMissingDocumentation are supported. - * - * @return {CancellablePromise} - ### - lint: (file, source, options = {}) -> - if not file - return new CancellablePromise (resolve, reject) -> - reject('No file passed!') - - if not @getIndexDatabasePath()? - return new CancellablePromise (resolve, reject) -> - reject('Request aborted as there is no project active (yet)') - - parameters = { - database : @getIndexDatabasePath() - file : @phpInvoker.normalizePlatformAndRuntimePath(file) - stdin : true - } - - if options.noUnknownClasses == true - parameters['no-unknown-classes'] = true - - if options.noUnknownMembers == true - parameters['no-unknown-members'] = true - - if options.noUnknownGlobalFunctions == true - parameters['no-unknown-global-functions'] = true - - if options.noUnknownGlobalConstants == true - parameters['no-unknown-global-constants'] = true - - if options.noDocblockCorrectness == true - parameters['no-docblock-correctness'] = true - - if options.noUnusedUseStatements == true - parameters['no-unused-use-statements'] = true - - if options.noMissingDocumentation == true - parameters['no-missing-documentation'] = true - - return @performRequest('lint', parameters, null, source) - - ###* - * Fetches all available variables at a specific location. - * - * @param {String} file The path to the file to examine. May be null if the source parameter is passed. - * @param {String|null} source The source code to search. May be null if a file is passed instead. - * @param {Number} offset The character offset into the file to examine. - * - * @return {Promise} - ### - getAvailableVariables: (file, source, offset) -> - if not file - return new CancellablePromise (resolve, reject) -> - reject('No file passed!') - - if not @getIndexDatabasePath()? - return new CancellablePromise (resolve, reject) -> - reject('Request aborted as there is no project active (yet)') - - parameters = { - database : @getIndexDatabasePath() - offset : offset - charoffset : true - file : @phpInvoker.normalizePlatformAndRuntimePath(file) - } - - return @performRequest('availableVariables', parameters, null, source) - - ###* - * Fetches the contents of the tooltip to display at the specified offset. - * - * @param {String} file The path to the file to examine. - * @param {String|null} source The source code to search. May be null if a file is passed instead. - * @param {Number} offset The character offset into the file to examine. - * - * @return {CancellablePromise} - ### - tooltip: (file, source, offset) -> - if not file? - return new CancellablePromise (resolve, reject) -> - reject('Either a path to a file or source code must be passed!') - - if not @getIndexDatabasePath()? - return new CancellablePromise (resolve, reject) -> - reject('Request aborted as there is no project active (yet)') - - parameters = { - database : @getIndexDatabasePath() - offset : offset - charoffset : true - file : @phpInvoker.normalizePlatformAndRuntimePath(file) - } - - return @performRequest('tooltip', parameters, null, source) - - ###* - * Fetches signature help for a method or function call. - * - * @param {String} file The path to the file to examine. - * @param {String|null} source The source code to search. May be null if a file is passed instead. - * @param {Number} offset The character offset into the file to examine. - * - * @return {CancellablePromise} - ### - signatureHelp: (file, source, offset) -> - if not file? - return new CancellablePromise (resolve, reject) -> - reject('Either a path to a file or source code must be passed!') - - if not @getIndexDatabasePath()? - return new CancellablePromise (resolve, reject) -> - reject('Request aborted as there is no project active (yet)') - - parameters = { - database : @getIndexDatabasePath() - offset : offset - charoffset : true - file : @phpInvoker.normalizePlatformAndRuntimePath(file) - } - - return @performRequest('signatureHelp', parameters, null, source) - - ###* - * Fetches definition information for code navigation purposes of the structural element at the specified location. - * - * @param {String} file The path to the file to examine. - * @param {String|null} source The source code to search. May be null if a file is passed instead. - * @param {Number} offset The character offset into the file to examine. - * - * @return {CancellablePromise} - ### - gotoDefinition: (file, source, offset) -> - if not file? - return new CancellablePromise (resolve, reject) -> - reject('Either a path to a file or source code must be passed!') - - if not @getIndexDatabasePath()? - return new CancellablePromise (resolve, reject) -> - reject('Request aborted as there is no project active (yet)') - - parameters = { - database : @getIndexDatabasePath() - offset : offset - charoffset : true - file : @phpInvoker.normalizePlatformAndRuntimePath(file) - } - - return @performRequest('gotoDefinition', parameters, null, source) - - ###* - * Deduces the resulting types of an expression. - * - * @param {String|null} expression The expression to deduce the type of, e.g. '$this->foo()'. If null, the - * expression just before the specified offset will be used. - * @param {String} file The path to the file to examine. - * @param {String|null} source The source code to search. May be null if a file is passed instead. - * @param {Number} offset The character offset into the file to examine. - * @param {bool} ignoreLastElement Whether to remove the last element or not, this is useful when the user - * is still writing code, e.g. "$this->foo()->b" would normally return the - * type (class) of 'b', as it is the last element, but as the user is still - * writing code, you may instead be interested in the type of 'foo()' - * instead. - * - * @return {Promise} - ### - deduceTypes: (expression, file, source, offset, ignoreLastElement) -> - if not file? - return new CancellablePromise (resolve, reject) -> - reject('A path to a file must be passed!') - - if not @getIndexDatabasePath()? - return new CancellablePromise (resolve, reject) -> - reject('Request aborted as there is no project active (yet)') - - parameters = { - database : @getIndexDatabasePath() - offset : offset - charoffset : true - } - - if file? - parameters.file = @phpInvoker.normalizePlatformAndRuntimePath(file) - - if ignoreLastElement - parameters['ignore-last-element'] = true - - if expression? - parameters.expression = expression - - return @performRequest('deduceTypes', parameters, null, source) - - ###* - * Retrieves autocompletion suggestions for a specific location. - * - * @param {Number} offset The character offset into the file to examine. - * @param {String} file The path to the file to examine. - * @param {String|null} source The source code to search. May be null if a file is passed instead. - * - * @return {CancellablePromise} - ### - autocomplete: (offset, file, source) -> - if not file? - return new CancellablePromise (resolve, reject) -> - reject('A path to a file must be passed!') - - if not @getIndexDatabasePath()? - return new CancellablePromise (resolve, reject) -> - reject('Request aborted as there is no project active (yet)') - - parameters = { - database : @getIndexDatabasePath() - offset : offset - charoffset : true - } - - if file? - parameters.file = @phpInvoker.normalizePlatformAndRuntimePath(file) - - return @performRequest('autocomplete', parameters, null, source) - - ###* - * Initializes a project. - * - * @return {Promise} - ### - initialize: () -> - if not @getIndexDatabasePath()? - return new CancellablePromise (resolve, reject) -> - reject('Request aborted as there is no project active (yet)') - - parameters = { - database : @getIndexDatabasePath() - } - - return @performRequest('initialize', parameters, null, null) - - ###* - * Shuts the server down entirely. - ### - exit: () -> - handler = () => - # Ignore promise rejection. - - @performRequest('exit', {}, null, null).then(handler, handler) - - return - - ###* - * Vacuums a project, cleaning up the index database (e.g. pruning files that no longer exist). - * - * @return {Promise} - ### - vacuum: () -> - if not @getIndexDatabasePath()? - return new CancellablePromise (resolve, reject) -> - reject('Request aborted as there is no project active (yet)') - - parameters = { - database : @getIndexDatabasePath() - } - - return @performRequest('vacuum', parameters, null, null) - - ###* - * Tests a project, to see if it is in a properly usable state. - * - * @return {Promise} - ### - test: () -> - if not @getIndexDatabasePath()? - return new CancellablePromise (resolve, reject) -> - reject('Request aborted as there is no project active (yet)') - - parameters = { - database : @getIndexDatabasePath() - } - - return @performRequest('test', parameters, null, null) - - ###* - * Refreshes the specified file or folder. This method is asynchronous and will return immediately. - * - * @param {String|Array} path The full path to the file or folder to refresh. Alternatively, - * this can be a list of items to index at the same time. - * @param {String|null} source The source code of the file to index. May be null if a directory is - * passed instead. - * @param {Callback|null} progressStreamCallback A method to invoke each time progress streaming data is received. - * @param {Array} excludedPaths A list of paths to exclude from indexing. - * @param {Array} fileExtensionsToIndex A list of file extensions (without leading dot) to index. - * - * @return {CancellablePromise} - ### - reindex: (path, source, progressStreamCallback, excludedPaths, fileExtensionsToIndex) -> - if typeof path == "string" - pathsToIndex = [] - - if path - pathsToIndex.push(path) - - else - pathsToIndex = path - - if path.length == 0 - return new CancellablePromise (resolve, reject) -> - reject('No filename passed!') - - if not @getIndexDatabasePath()? - return new CancellablePromise (resolve, reject) -> - reject('Request aborted as there is no project active (yet)') - - progressStreamCallbackWrapper = null - - parameters = { - database : @getIndexDatabasePath() - } - - if progressStreamCallback? - progressStreamCallbackWrapper = progressStreamCallback - - pathsToIndex = pathsToIndex.map (path) => - return @phpInvoker.normalizePlatformAndRuntimePath(path) - - parameters.source = pathsToIndex - parameters.exclude = excludedPaths - parameters.extension = fileExtensionsToIndex - - return @performRequest('reindex', parameters, progressStreamCallbackWrapper, source) - - ###* - * Sets the name (without path or extension) of the database file to use. - * - * @param {String} name - ### - setIndexDatabaseName: (name) -> - @indexDatabaseName = sanitize(name) - - ###* - * Retrieves the full path to the database file to use. - * - * @return {String|null} - ### - getIndexDatabasePath: () -> - if not @indexDatabaseName? - return null - - folder = @config.get('storagePath') + path.sep + 'indexes' - - mkdirp.sync(folder) - - return @phpInvoker.normalizePlatformAndRuntimePath(folder + path.sep + @indexDatabaseName + '.sqlite') - - ###* - * @param {String} corePath - ### - setCorePath: (corePath) -> - @corePath = corePath - - ###* - * @return {Boolean} - ### - getIsActive: (isActive) -> - return @isActive - - ###* - * @param {Boolean} isActive - ### - setIsActive: (isActive) -> - @isActive = isActive diff --git a/lib/Proxy.js b/lib/Proxy.js new file mode 100644 index 00000000..4181b5b6 --- /dev/null +++ b/lib/Proxy.js @@ -0,0 +1,1349 @@ +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * DS206: Consider reworking classes to avoid initClass + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let Proxy; +const fs = require('fs'); +const net = require('net'); +const path = require('path'); +const mkdirp = require('mkdirp'); +const stream = require('stream'); +const child_process = require('child_process'); +const sanitize = require('sanitize-filename'); + +const CancellablePromise = require('./CancellablePromise'); + +module.exports = + +//#* +// Proxy that handles communicating with the PHP side. +//# +(Proxy = (function() { + Proxy = class Proxy { + static initClass() { + /** + * The config to use. + * + * @var {Object} + */ + this.prototype.config = null; + + /** + * @var {Object} + */ + this.prototype.phpInvoker = null; + + /** + * The name (without path or extension) of the database file to use. + * + * @var {Object} + */ + this.prototype.indexDatabaseName = null; + + /** + * @var {Boolean} + */ + this.prototype.isActive = false; + + /** + * @var {String} + */ + this.prototype.corePath = null; + + /** + * @var {Object} + */ + this.prototype.phpServer = null; + + /** + * @var {CancellablePromise} + */ + this.prototype.phpServerPromise = null; + + /** + * @var {Object} + */ + this.prototype.client = null; + + /** + * @var {Object} + */ + this.prototype.requestQueue = null; + + /** + * @var {Number} + */ + this.prototype.nextRequestId = 1; + + /** + * @var {Object} + */ + this.prototype.response = null; + + /** + * @var {String} + */ + this.prototype.HEADER_DELIMITER = "\r\n"; + + /** + * @var {Number} + */ + this.prototype.FATAL_SERVER_ERROR = -32000; + } + + /** + * Constructor. + * + * @param {Config} config + * @param {PhpInvoker} phpInvoker + */ + constructor(config, phpInvoker) { + this.config = config; + this.phpInvoker = phpInvoker; + this.requestQueue = {}; + this.port = this.getRandomServerPort(); + + this.resetResponseState(); + } + + /** + * Spawns the PHP socket server process. + * + * @param {Number} port + * + * @return {Promise} + */ + spawnPhpServer(port) { + const memoryLimit = this.config.get('core.memoryLimit'); + const socketHost = this.config.get('core.phpExecutionType') === 'host' ? '127.0.0.1' : '0.0.0.0'; + + const parameters = [ + // Enable these to profile (requires xdebug be installed). + //'-d zend_extension=/usr/lib/php/modules/xdebug.so' + //'-d xdebug.profiler_enable=On', + //'-d xdebug.profiler_output_dir=/tmp', + + `-d memory_limit=${memoryLimit}M`, + this.phpInvoker.normalizePlatformAndRuntimePath(this.corePath) + "/src/Main.php", + `--uri=tcp://${socketHost}:${port}` + ]; + + const additionalDockerRunParameters = [ + '-p', `127.0.0.1:${port}:${port}` + ]; + + const process = this.phpInvoker.invoke(parameters, additionalDockerRunParameters); + + return new Promise((resolve, reject) => { + process.stdout.on('data', data => { + const message = data.toString(); + + console.debug('The PHP server has something to say:', message); + + if (message.startsWith('Starting socket server')) { + // Assume the server has successfully spawned the moment it says its first words. + return resolve(process); + } + }); + + process.stderr.on('data', data => { + return console.warn('The PHP server has errors to report:', data.toString()); + }); + + process.on('error', error => { + console.error('An error ocurred whilst invoking PHP', error); + return reject(); + }); + + return process.on('close', code => { + if (code === 2) { + console.error(`Port ${port} is already taken`); + return; + + } else if (code !== 0) { + const detail = + "Serenata unexpectedly closed. Either something caused the process to stop, it crashed, or " + + "the socket closed. In case of the first two, you should see additional output indicating " + + "this is the case and you can report a bug. If there is no additional output, the server may " + + "have run out of memory (you can increase it via the settings screen)."; + + console.error(detail); + } + + this.closeServerConnection(); + + this.phpServer = null; + return reject(); + }); + }); + } + + /** + * @return {Number} + */ + getRandomServerPort() { + const minPort = 10000; + const maxPort = 40000; + + return Math.floor((Math.random() * (maxPort - minPort)) + minPort); + } + + /** + * Spawns the PHP socket server process. + * + * @param {Number} port + * + * @return {Promise} + */ + spawnPhpServerIfNecessary(port) { + if (this.phpServer) { + this.phpServerPromise = null; + + return new Promise((resolve, reject) => { + return resolve(this.phpServer); + }); + + } else if (this.phpServerPromise) { + return this.phpServerPromise; + } + + const successHandler = phpServer => { + this.phpServer = phpServer; + + return phpServer; + }; + + const failureHandler = () => { + return this.phpServerPromise = null; + }; + + this.phpServerPromise = this.spawnPhpServer(port).then(successHandler, failureHandler); + + return this.phpServerPromise; + } + + /** + * Closes the socket connection to the server. + */ + closeServerConnection() { + this.rejectAllOpenRequests(); + + if (!this.client) { return; } + + this.client.destroy(); + this.client = null; + + return this.resetResponseState(); + } + + /** + * Rejects all currently open requests. + */ + rejectAllOpenRequests() { + for (let id in this.requestQueue) { + const request = this.requestQueue[id]; + request.promise.reject('Socket connection encountered invalid state or was closed, please resend request'); + } + + return this.requestQueue = {}; + } + + /** + * @return {Promise} + */ + getSocketConnection() { + return new Promise((resolve, reject) => { + return this.spawnPhpServerIfNecessary(this.port).then(() => { + if (this.client != null) { + resolve(this.client); + return; + } + + this.client = net.createConnection({port: this.port}, () => { + return resolve(this.client); + }); + + this.client.setNoDelay(true); + this.client.on('error', this.onSocketError.bind(this)); + this.client.on('data', this.onDataReceived.bind(this)); + return this.client.on('close', this.onConnectionClosed.bind(this)); + }); + }); + } + + /** + * @param {String} data + */ + onDataReceived(data) { + try { + return this.processDataBuffer(data); + + } catch (error) { + console.warn('Encountered some invalid data, resetting state. Error: ', error); + + return this.resetResponseState(); + } + } + + /** + * @param {Object} error + */ + onSocketError(error) { + // Do nothing here, this should silence socket errors such as ECONNRESET. After this is called, the socket will + // be closed and all handling is performed there. + return console.warn('The socket connection notified us of an error', error); + } + + /** + * @param {Boolean} hadError + */ + onConnectionClosed(hadError) { + return this.closeServerConnection(); + } + + /** + * @param {Buffer} dataBuffer + */ + processDataBuffer(dataBuffer) { + let bytesRead; + if ((this.response.length == null)) { + const contentLengthHeader = this.readRawHeader(dataBuffer); + this.response.length = this.getLengthFromContentLengthHeader(contentLengthHeader); + + bytesRead = contentLengthHeader.length + this.HEADER_DELIMITER.length; + + } else if (!this.response.wasBoundaryFound) { + const header = this.readRawHeader(dataBuffer); + + if (header.length === 0) { + this.response.wasBoundaryFound = true; + } + + bytesRead = header.length + this.HEADER_DELIMITER.length; + + } else { + bytesRead = Math.min(dataBuffer.length, this.response.length - this.response.bytesRead); + + this.response.content = Buffer.concat([this.response.content, dataBuffer.slice(0, bytesRead)]); + this.response.bytesRead += bytesRead; + + if (this.response.bytesRead === this.response.length) { + const jsonRpcResponse = this.getJsonRpcResponseFromResponseBuffer(this.response.content); + + this.processJsonRpcResponse(jsonRpcResponse); + + this.resetResponseState(); + } + } + + dataBuffer = dataBuffer.slice(bytesRead); + + if (dataBuffer.length > 0) { + return this.processDataBuffer(dataBuffer); + } + } + + /** + * @param {Object} jsonRpcResponse + */ + processJsonRpcResponse(jsonRpcResponse) { + if (jsonRpcResponse.id != null) { + const jsonRpcRequest = this.requestQueue[jsonRpcResponse.id]; + + if ((jsonRpcRequest == null)) { + console.warn('Received response for request that was already removed from the queue', jsonRpcResponse); + return; + } + + this.processJsonRpcResponseForRequest(jsonRpcResponse, jsonRpcRequest); + + return delete this.requestQueue[jsonRpcResponse.id]; + + } else { + return this.processNotificationJsonRpcResponse(jsonRpcResponse); + } + } + + /** + * @param {Object} jsonRpcResponse + * @param {Object} jsonRpcRequest + */ + processJsonRpcResponseForRequest(jsonRpcResponse, jsonRpcRequest) { + if (jsonRpcResponse.error != null) { + jsonRpcRequest.promise.reject({ + request : jsonRpcRequest, + response : jsonRpcResponse, + error : jsonRpcResponse.error + }); + + if (jsonRpcResponse.error.code === this.FATAL_SERVER_ERROR) { + return this.showFatalServerError(jsonRpcResponse.error); + } + + } else { + return jsonRpcRequest.promise.resolve(jsonRpcResponse.result); + } + } + + /** + * @param {Object} jsonRpcResponse + * @param {Object} jsonRpcRequest + */ + processNotificationJsonRpcResponse(jsonRpcResponse) { + if ((jsonRpcResponse.result == null)) { + console.warn('Received a server notification without a result', jsonRpcResponse); + return; + } + + if (jsonRpcResponse.result.type === 'reindexProgressInformation') { + if ((jsonRpcResponse.result.requestId == null)) { + console.warn('Received progress information without a request ID to go with it', jsonRpcResponse); + return; + } + + const relatedJsonRpcRequest = this.requestQueue[jsonRpcResponse.result.requestId]; + + if ((relatedJsonRpcRequest == null)) { + console.warn( + 'Received progress information for request that doesn\'t exist or was already finished', + jsonRpcResponse + ); + return; + + } else if ((relatedJsonRpcRequest.streamCallback == null)) { + console.warn('Received progress information for a request that isn\'t interested in it', jsonRpcResponse); + return; + } + + return relatedJsonRpcRequest.streamCallback(jsonRpcResponse.result.progress); + + } else { + return console.warn('Received a server notification with an unknown type', jsonRpcResponse); + } + } + + /** + * @param {Buffer} dataBuffer + * + * @return {Object} + */ + getJsonRpcResponseFromResponseBuffer(dataBuffer) { + const jsonRpcResponseString = dataBuffer.toString(); + + return this.getJsonRpcResponseFromResponseContent(jsonRpcResponseString); + } + + /** + * @param {String} content + * + * @return {Object} + */ + getJsonRpcResponseFromResponseContent(content) { + return JSON.parse(content); + } + + /** + * @param {Buffer} dataBuffer + * + * @throws {Error} + * + * @return {String} + */ + readRawHeader(dataBuffer) { + const end = dataBuffer.indexOf(this.HEADER_DELIMITER); + + if (end === -1) { + throw new Error('Header delimiter not found'); + } + + return dataBuffer.slice(0, end).toString(); + } + + /** + * @param {String} rawHeader + * + * @throws {Error} + * + * @return {Number} + */ + getLengthFromContentLengthHeader(rawHeader) { + const parts = rawHeader.split(':'); + + if (parts.length !== 2) { + throw new Error('Unexpected amount of header parts found'); + } + + const contentLength = parseInt(parts[1]); + + if ((contentLength == null)) { + throw new Error('Content length header does not have an integer as value'); + } + + return contentLength; + } + + /** + * Resets the current response's state. + */ + resetResponseState() { + return this.response = { + length : null, + wasBoundaryFound : false, + bytesRead : 0, + content : new Buffer([]) + }; + } + + /** + * Performs an asynchronous request to the PHP side. + * + * @param {Number} id + * @param {String} method + * @param {Object} parameters + * @param {Callback} streamCallback + * + * @return {CancellablePromise} + */ + performJsonRpcRequest(id, method, parameters, streamCallback = null) { + const executor = (resolve, reject) => { + if (!this.getIsActive()) { + reject('The proxy is not yet active, the core may be in the process of being downloaded'); + return; + } + + const jsonRpcRequest = { + jsonrpc : 2.0, + id, + method, + params : parameters + }; + + this.requestQueue[id] = { + id, + streamCallback, + request : jsonRpcRequest, + + promise: { + resolve, + reject + } + }; + + const content = this.getContentForJsonRpcRequest(jsonRpcRequest); + + return this.writeRawRequest(content); + }; + + const cancelHandler = () => { + return this.performRequest('cancelRequest', {id}); + }; + + return new CancellablePromise(executor, cancelHandler); + } + + /** + * @param {Object} request + * + * @return {String} + */ + getContentForJsonRpcRequest(request) { + return JSON.stringify(request); + } + + /** + * Writes a raw request to the connection. + * + * This may not happen immediately if the connection is not available yet. In that case, the request will be + * dispatched as soon as the connection becomes available. + * + * @param {String} content The content (body) of the request. + */ + writeRawRequest(content) { + return this.getSocketConnection().then(connection => { + const lengthInBytes = (new TextEncoder('utf-8').encode(content)).length; + + connection.write(`Content-Length: ${lengthInBytes}${this.HEADER_DELIMITER}`); + connection.write(this.HEADER_DELIMITER); + return connection.write(content); + }); + } + + /** + * @param {Object} error + */ + showFatalServerError(error) { + let notification; + const detail = + "You've likely hit a snag in the Serenata server. Feel free to report it on its bug tracker. " + + "If you do, please include the information printed below.\n \n" + + + "Please do *not* report this to the issue tracker of this package on GitHub as it is not a bug here.\n \n" + + + "The server will attempt to restart itself.\n \n" + + + error.data.backtrace; + + return notification = atom.notifications.addError('Serenata - Darn, we\'ve crashed!', { + dismissable : true, + detail, + + buttons: [ + { + text: 'Open issue tracker', + onDidClick() { + const {shell} = require('electron'); + return shell.openExternal('https://gitlab.com/Serenata/Serenata/issues'); + } + }, + + { + text: 'Dismiss', + onDidClick() { + return notification.dismiss(); + } + } + ] + }); + } + + /** + * @param {String} method + * @param {Object} parameters + * @param {Callback} streamCallback A method to invoke each time streaming data is received. + * @param {String|null} stdinData The data to pass to STDIN. + * + * @return {CancellablePromise} + */ + performRequest(method, parameters, streamCallback = null, stdinData = null) { + if (stdinData != null) { + parameters.stdin = true; + parameters.stdinData = stdinData; + } + + const requestId = this.nextRequestId++; + + return this.performJsonRpcRequest(requestId, method, parameters, streamCallback); + } + + /** + * Retrieves a list of available classes. + * + * @return {Promise} + */ + getClassList() { + if ((this.getIndexDatabasePath() == null)) { + return new CancellablePromise(function(resolve, reject) { + return reject('Request aborted as there is no project active (yet)'); + }); + } + + const parameters = { + database : this.getIndexDatabasePath() + }; + + return this.performRequest('classList', parameters); + } + + /** + * Retrieves a list of available classes in the specified file. + * + * @param {String} file + * + * @return {Promise} + */ + getClassListForFile(file) { + if (!file) { + return new CancellablePromise(function(resolve, reject) { + return reject('No file passed!'); + }); + } + + if ((this.getIndexDatabasePath() == null)) { + return new CancellablePromise(function(resolve, reject) { + return reject('Request aborted as there is no project active (yet)'); + }); + } + + const parameters = { + database : this.getIndexDatabasePath(), + file : this.phpInvoker.normalizePlatformAndRuntimePath(file) + }; + + return this.performRequest('classList', parameters); + } + + /** + * Retrieves a list of namespaces. + * + * @return {Promise} + */ + getNamespaceList() { + if ((this.getIndexDatabasePath() == null)) { + return new CancellablePromise(function(resolve, reject) { + return reject('Request aborted as there is no project active (yet)'); + }); + } + + const parameters = { + database : this.getIndexDatabasePath() + }; + + return this.performRequest('namespaceList', parameters); + } + + /** + * Retrieves a list of namespaces in the specified file. + * + * @param {String} file + * + * @return {Promise} + */ + getNamespaceListForFile(file) { + if (!file) { + return new CancellablePromise(function(resolve, reject) { + return reject('No file passed!'); + }); + } + + if ((this.getIndexDatabasePath() == null)) { + return new CancellablePromise(function(resolve, reject) { + return reject('Request aborted as there is no project active (yet)'); + }); + } + + const parameters = { + database : this.getIndexDatabasePath(), + file : this.phpInvoker.normalizePlatformAndRuntimePath(file) + }; + + return this.performRequest('namespaceList', parameters); + } + + /** + * Retrieves a list of available global constants. + * + * @return {Promise} + */ + getGlobalConstants() { + if ((this.getIndexDatabasePath() == null)) { + return new CancellablePromise(function(resolve, reject) { + return reject('Request aborted as there is no project active (yet)'); + }); + } + + const parameters = { + database : this.getIndexDatabasePath() + }; + + return this.performRequest('globalConstants', parameters); + } + + /** + * Retrieves a list of available global functions. + * + * @return {Promise} + */ + getGlobalFunctions() { + if ((this.getIndexDatabasePath() == null)) { + return new CancellablePromise(function(resolve, reject) { + return reject('Request aborted as there is no project active (yet)'); + }); + } + + const parameters = { + database : this.getIndexDatabasePath() + }; + + return this.performRequest('globalFunctions', parameters); + } + + /** + * Retrieves a list of available members of the class (or interface, trait, ...) with the specified name. + * + * @param {String} className + * + * @return {Promise} + */ + getClassInfo(className) { + if (!className) { + return new CancellablePromise(function(resolve, reject) { + return reject('No class name passed!'); + }); + } + + if ((this.getIndexDatabasePath() == null)) { + return new CancellablePromise(function(resolve, reject) { + return reject('Request aborted as there is no project active (yet)'); + }); + } + + const parameters = { + database : this.getIndexDatabasePath(), + name : className + }; + + return this.performRequest('classInfo', parameters); + } + + /** + * Resolves a local type in the specified file, based on use statements and the namespace. + * + * @param {String} file + * @param {Number} line The line the type is located at. The first line is 1, not 0. + * @param {String} type + * @param {String} kind The kind of element. Either 'classlike', 'constant' or 'function'. + * + * @return {Promise} + */ + resolveType(file, line, type, kind) { + if (kind == null) { kind = 'classlike'; } + if (!file) { + return new CancellablePromise(function(resolve, reject) { + return reject('No file passed!'); + }); + } + + if (!line) { + return new CancellablePromise(function(resolve, reject) { + return reject('No line passed!'); + }); + } + + if (!type) { + return new CancellablePromise(function(resolve, reject) { + return reject('No type passed!'); + }); + } + + if (!kind) { + return new CancellablePromise(function(resolve, reject) { + return reject('No kind passed!'); + }); + } + + if ((this.getIndexDatabasePath() == null)) { + return new CancellablePromise(function(resolve, reject) { + return reject('Request aborted as there is no project active (yet)'); + }); + } + + const parameters = { + database : this.getIndexDatabasePath(), + file : this.phpInvoker.normalizePlatformAndRuntimePath(file), + line, + type, + kind + }; + + return this.performRequest('resolveType', parameters); + } + + /** + * Localizes a type to the specified file, making it relative to local use statements, if possible. If not possible, + * null is returned. + * + * @param {String} file + * @param {Number} line The line the type is located at. The first line is 1, not 0. + * @param {String} type + * @param {String} kind The kind of element. Either 'classlike', 'constant' or 'function'. + * + * @return {Promise} + */ + localizeType(file, line, type, kind) { + if (kind == null) { kind = 'classlike'; } + if (!file) { + return new CancellablePromise(function(resolve, reject) { + return reject('No file passed!'); + }); + } + + if (!line) { + return new CancellablePromise(function(resolve, reject) { + return reject('No line passed!'); + }); + } + + if (!type) { + return new CancellablePromise(function(resolve, reject) { + return reject('No type passed!'); + }); + } + + if (!kind) { + return new CancellablePromise(function(resolve, reject) { + return reject('No kind passed!'); + }); + } + + if ((this.getIndexDatabasePath() == null)) { + return new CancellablePromise(function(resolve, reject) { + return reject('Request aborted as there is no project active (yet)'); + }); + } + + const parameters = { + database : this.getIndexDatabasePath(), + file : this.phpInvoker.normalizePlatformAndRuntimePath(file), + line, + type, + kind + }; + + return this.performRequest('localizeType', parameters); + } + + /** + * Lints the specified file. + * + * @param {String} file + * @param {String|null} source The source code of the file to index. May be null if a directory is passed instead. + * @param {Object} options Additional options to set. Boolean properties noUnknownClasses, noUnknownMembers, + * noUnknownGlobalFunctions, noUnknownGlobalConstants, noDocblockCorrectness, + * noUnusedUseStatements and noMissingDocumentation are supported. + * + * @return {CancellablePromise} + */ + lint(file, source, options) { + if (options == null) { options = {}; } + if (!file) { + return new CancellablePromise(function(resolve, reject) { + return reject('No file passed!'); + }); + } + + if ((this.getIndexDatabasePath() == null)) { + return new CancellablePromise(function(resolve, reject) { + return reject('Request aborted as there is no project active (yet)'); + }); + } + + const parameters = { + database : this.getIndexDatabasePath(), + file : this.phpInvoker.normalizePlatformAndRuntimePath(file), + stdin : true + }; + + if (options.noUnknownClasses === true) { + parameters['no-unknown-classes'] = true; + } + + if (options.noUnknownMembers === true) { + parameters['no-unknown-members'] = true; + } + + if (options.noUnknownGlobalFunctions === true) { + parameters['no-unknown-global-functions'] = true; + } + + if (options.noUnknownGlobalConstants === true) { + parameters['no-unknown-global-constants'] = true; + } + + if (options.noDocblockCorrectness === true) { + parameters['no-docblock-correctness'] = true; + } + + if (options.noUnusedUseStatements === true) { + parameters['no-unused-use-statements'] = true; + } + + if (options.noMissingDocumentation === true) { + parameters['no-missing-documentation'] = true; + } + + return this.performRequest('lint', parameters, null, source); + } + + /** + * Fetches all available variables at a specific location. + * + * @param {String} file The path to the file to examine. May be null if the source parameter is passed. + * @param {String|null} source The source code to search. May be null if a file is passed instead. + * @param {Number} offset The character offset into the file to examine. + * + * @return {Promise} + */ + getAvailableVariables(file, source, offset) { + if (!file) { + return new CancellablePromise(function(resolve, reject) { + return reject('No file passed!'); + }); + } + + if ((this.getIndexDatabasePath() == null)) { + return new CancellablePromise(function(resolve, reject) { + return reject('Request aborted as there is no project active (yet)'); + }); + } + + const parameters = { + database : this.getIndexDatabasePath(), + offset, + charoffset : true, + file : this.phpInvoker.normalizePlatformAndRuntimePath(file) + }; + + return this.performRequest('availableVariables', parameters, null, source); + } + + /** + * Fetches the contents of the tooltip to display at the specified offset. + * + * @param {String} file The path to the file to examine. + * @param {String|null} source The source code to search. May be null if a file is passed instead. + * @param {Number} offset The character offset into the file to examine. + * + * @return {CancellablePromise} + */ + tooltip(file, source, offset) { + if ((file == null)) { + return new CancellablePromise(function(resolve, reject) { + return reject('Either a path to a file or source code must be passed!'); + }); + } + + if ((this.getIndexDatabasePath() == null)) { + return new CancellablePromise(function(resolve, reject) { + return reject('Request aborted as there is no project active (yet)'); + }); + } + + const parameters = { + database : this.getIndexDatabasePath(), + offset, + charoffset : true, + file : this.phpInvoker.normalizePlatformAndRuntimePath(file) + }; + + return this.performRequest('tooltip', parameters, null, source); + } + + /** + * Fetches signature help for a method or function call. + * + * @param {String} file The path to the file to examine. + * @param {String|null} source The source code to search. May be null if a file is passed instead. + * @param {Number} offset The character offset into the file to examine. + * + * @return {CancellablePromise} + */ + signatureHelp(file, source, offset) { + if ((file == null)) { + return new CancellablePromise(function(resolve, reject) { + return reject('Either a path to a file or source code must be passed!'); + }); + } + + if ((this.getIndexDatabasePath() == null)) { + return new CancellablePromise(function(resolve, reject) { + return reject('Request aborted as there is no project active (yet)'); + }); + } + + const parameters = { + database : this.getIndexDatabasePath(), + offset, + charoffset : true, + file : this.phpInvoker.normalizePlatformAndRuntimePath(file) + }; + + return this.performRequest('signatureHelp', parameters, null, source); + } + + /** + * Fetches definition information for code navigation purposes of the structural element at the specified location. + * + * @param {String} file The path to the file to examine. + * @param {String|null} source The source code to search. May be null if a file is passed instead. + * @param {Number} offset The character offset into the file to examine. + * + * @return {CancellablePromise} + */ + gotoDefinition(file, source, offset) { + if ((file == null)) { + return new CancellablePromise(function(resolve, reject) { + return reject('Either a path to a file or source code must be passed!'); + }); + } + + if ((this.getIndexDatabasePath() == null)) { + return new CancellablePromise(function(resolve, reject) { + return reject('Request aborted as there is no project active (yet)'); + }); + } + + const parameters = { + database : this.getIndexDatabasePath(), + offset, + charoffset : true, + file : this.phpInvoker.normalizePlatformAndRuntimePath(file) + }; + + return this.performRequest('gotoDefinition', parameters, null, source); + } + + /** + * Deduces the resulting types of an expression. + * + * @param {String|null} expression The expression to deduce the type of, e.g. '$this->foo()'. If null, the + * expression just before the specified offset will be used. + * @param {String} file The path to the file to examine. + * @param {String|null} source The source code to search. May be null if a file is passed instead. + * @param {Number} offset The character offset into the file to examine. + * @param {bool} ignoreLastElement Whether to remove the last element or not, this is useful when the user + * is still writing code, e.g. "$this->foo()->b" would normally return the + * type (class) of 'b', as it is the last element, but as the user is still + * writing code, you may instead be interested in the type of 'foo()' + * instead. + * + * @return {Promise} + */ + deduceTypes(expression, file, source, offset, ignoreLastElement) { + if ((file == null)) { + return new CancellablePromise(function(resolve, reject) { + return reject('A path to a file must be passed!'); + }); + } + + if ((this.getIndexDatabasePath() == null)) { + return new CancellablePromise(function(resolve, reject) { + return reject('Request aborted as there is no project active (yet)'); + }); + } + + const parameters = { + database : this.getIndexDatabasePath(), + offset, + charoffset : true + }; + + if (file != null) { + parameters.file = this.phpInvoker.normalizePlatformAndRuntimePath(file); + } + + if (ignoreLastElement) { + parameters['ignore-last-element'] = true; + } + + if (expression != null) { + parameters.expression = expression; + } + + return this.performRequest('deduceTypes', parameters, null, source); + } + + /** + * Retrieves autocompletion suggestions for a specific location. + * + * @param {Number} offset The character offset into the file to examine. + * @param {String} file The path to the file to examine. + * @param {String|null} source The source code to search. May be null if a file is passed instead. + * + * @return {CancellablePromise} + */ + autocomplete(offset, file, source) { + if ((file == null)) { + return new CancellablePromise(function(resolve, reject) { + return reject('A path to a file must be passed!'); + }); + } + + if ((this.getIndexDatabasePath() == null)) { + return new CancellablePromise(function(resolve, reject) { + return reject('Request aborted as there is no project active (yet)'); + }); + } + + const parameters = { + database : this.getIndexDatabasePath(), + offset, + charoffset : true + }; + + if (file != null) { + parameters.file = this.phpInvoker.normalizePlatformAndRuntimePath(file); + } + + return this.performRequest('autocomplete', parameters, null, source); + } + + /** + * Initializes a project. + * + * @return {Promise} + */ + initialize() { + if ((this.getIndexDatabasePath() == null)) { + return new CancellablePromise(function(resolve, reject) { + return reject('Request aborted as there is no project active (yet)'); + }); + } + + const parameters = { + database : this.getIndexDatabasePath() + }; + + return this.performRequest('initialize', parameters, null, null); + } + + /** + * Shuts the server down entirely. + */ + exit() { + const handler = () => {}; + // Ignore promise rejection. + + this.performRequest('exit', {}, null, null).then(handler, handler); + + } + + /** + * Vacuums a project, cleaning up the index database (e.g. pruning files that no longer exist). + * + * @return {Promise} + */ + vacuum() { + if ((this.getIndexDatabasePath() == null)) { + return new CancellablePromise(function(resolve, reject) { + return reject('Request aborted as there is no project active (yet)'); + }); + } + + const parameters = { + database : this.getIndexDatabasePath() + }; + + return this.performRequest('vacuum', parameters, null, null); + } + + /** + * Tests a project, to see if it is in a properly usable state. + * + * @return {Promise} + */ + test() { + if ((this.getIndexDatabasePath() == null)) { + return new CancellablePromise(function(resolve, reject) { + return reject('Request aborted as there is no project active (yet)'); + }); + } + + const parameters = { + database : this.getIndexDatabasePath() + }; + + return this.performRequest('test', parameters, null, null); + } + + /** + * Refreshes the specified file or folder. This method is asynchronous and will return immediately. + * + * @param {String|Array} path The full path to the file or folder to refresh. Alternatively, + * this can be a list of items to index at the same time. + * @param {String|null} source The source code of the file to index. May be null if a directory is + * passed instead. + * @param {Callback|null} progressStreamCallback A method to invoke each time progress streaming data is received. + * @param {Array} excludedPaths A list of paths to exclude from indexing. + * @param {Array} fileExtensionsToIndex A list of file extensions (without leading dot) to index. + * + * @return {CancellablePromise} + */ + reindex(path, source, progressStreamCallback, excludedPaths, fileExtensionsToIndex) { + let pathsToIndex; + if (typeof path === "string") { + pathsToIndex = []; + + if (path) { + pathsToIndex.push(path); + } + + } else { + pathsToIndex = path; + } + + if (path.length === 0) { + return new CancellablePromise(function(resolve, reject) { + return reject('No filename passed!'); + }); + } + + if ((this.getIndexDatabasePath() == null)) { + return new CancellablePromise(function(resolve, reject) { + return reject('Request aborted as there is no project active (yet)'); + }); + } + + let progressStreamCallbackWrapper = null; + + const parameters = { + database : this.getIndexDatabasePath() + }; + + if (progressStreamCallback != null) { + progressStreamCallbackWrapper = progressStreamCallback; + } + + pathsToIndex = pathsToIndex.map(path => { + return this.phpInvoker.normalizePlatformAndRuntimePath(path); + }); + + parameters.source = pathsToIndex; + parameters.exclude = excludedPaths; + parameters.extension = fileExtensionsToIndex; + + return this.performRequest('reindex', parameters, progressStreamCallbackWrapper, source); + } + + /** + * Sets the name (without path or extension) of the database file to use. + * + * @param {String} name + */ + setIndexDatabaseName(name) { + return this.indexDatabaseName = sanitize(name); + } + + /** + * Retrieves the full path to the database file to use. + * + * @return {String|null} + */ + getIndexDatabasePath() { + if ((this.indexDatabaseName == null)) { + return null; + } + + const folder = this.config.get('storagePath') + path.sep + 'indexes'; + + mkdirp.sync(folder); + + return this.phpInvoker.normalizePlatformAndRuntimePath(folder + path.sep + this.indexDatabaseName + '.sqlite'); + } + + /** + * @param {String} corePath + */ + setCorePath(corePath) { + return this.corePath = corePath; + } + + /** + * @return {Boolean} + */ + getIsActive(isActive) { + return this.isActive; + } + + /** + * @param {Boolean} isActive + */ + setIsActive(isActive) { + return this.isActive = isActive; + } + }; + Proxy.initClass(); + return Proxy; +})()); diff --git a/lib/Refactoring/AbstractProvider.coffee b/lib/Refactoring/AbstractProvider.coffee deleted file mode 100644 index 5f5fc28a..00000000 --- a/lib/Refactoring/AbstractProvider.coffee +++ /dev/null @@ -1,117 +0,0 @@ -module.exports = - -##* -# Base class for providers. -## -class AbstractProvider - ###* - * The service (that can be used to query the source code and contains utility methods). - ### - service: null - - ###* - * Service to insert snippets into the editor. - ### - snippetManager: null - - ###* - * Constructor. - ### - constructor: () -> - - ###* - * Initializes this provider. - * - * @param {mixed} service - ### - activate: (@service) -> - dependentPackage = 'language-php' - - # It could be that the dependent package is already active, in that case we can continue immediately. If not, - # we'll need to wait for the listener to be invoked - if atom.packages.isPackageActive(dependentPackage) - @doActualInitialization() - - atom.packages.onDidActivatePackage (packageData) => - return if packageData.name != dependentPackage - - @doActualInitialization() - - atom.packages.onDidDeactivatePackage (packageData) => - return if packageData.name != dependentPackage - - @deactivate() - - ###* - * Does the actual initialization. - ### - doActualInitialization: () -> - atom.workspace.observeTextEditors (editor) => - if /text.html.php$/.test(editor.getGrammar().scopeName) - @registerEvents(editor) - - # When you go back to only have one pane the events are lost, so need to re-register. - atom.workspace.onDidDestroyPane (pane) => - panes = atom.workspace.getPanes() - - if panes.length == 1 - @registerEventsForPane(panes[0]) - - # Having to re-register events as when a new pane is created the old panes lose the events. - atom.workspace.onDidAddPane (observedPane) => - panes = atom.workspace.getPanes() - - for pane in panes - if pane != observedPane - @registerEventsForPane(pane) - - ###* - * Registers the necessary event handlers for the editors in the specified pane. - * - * @param {Pane} pane - ### - registerEventsForPane: (pane) -> - for paneItem in pane.items - if atom.workspace.isTextEditor(paneItem) - if /text.html.php$/.test(paneItem.getGrammar().scopeName) - @registerEvents(paneItem) - - ###* - * Deactives the provider. - ### - deactivate: () -> - - ###* - * Retrieves intention providers (by default, the intentions menu shows when the user presses alt-enter). - * - * This method should be overwritten by subclasses. - * - * @return {array} - ### - getIntentionProviders: () -> - return [] - - ###* - * Registers the necessary event handlers. - * - * @param {TextEditor} editor TextEditor to register events to. - ### - registerEvents: (editor) -> - - ###* - * Sets the snippet manager - * - * @param {Object} @snippetManager - ### - setSnippetManager: (@snippetManager) -> - - ###* - * @return {Number|null} - ### - getCurrentProjectPhpVersion: () -> - projectSettings = @service.getCurrentProjectSettings() - - if projectSettings? - return projectSettings.phpVersion - - return null diff --git a/lib/Refactoring/AbstractProvider.js b/lib/Refactoring/AbstractProvider.js new file mode 100644 index 00000000..5f3d2810 --- /dev/null +++ b/lib/Refactoring/AbstractProvider.js @@ -0,0 +1,170 @@ +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS205: Consider reworking code to avoid use of IIFEs + * DS206: Consider reworking classes to avoid initClass + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let AbstractProvider; +module.exports = + +//#* +// Base class for providers. +//# +(AbstractProvider = (function() { + AbstractProvider = class AbstractProvider { + static initClass() { + /** + * The service (that can be used to query the source code and contains utility methods). + */ + this.prototype.service = null; + + /** + * Service to insert snippets into the editor. + */ + this.prototype.snippetManager = null; + } + + /** + * Constructor. + */ + constructor() {} + + /** + * Initializes this provider. + * + * @param {mixed} service + */ + activate(service) { + this.service = service; + const dependentPackage = 'language-php'; + + // It could be that the dependent package is already active, in that case we can continue immediately. If not, + // we'll need to wait for the listener to be invoked + if (atom.packages.isPackageActive(dependentPackage)) { + this.doActualInitialization(); + } + + atom.packages.onDidActivatePackage(packageData => { + if (packageData.name !== dependentPackage) { return; } + + return this.doActualInitialization(); + }); + + return atom.packages.onDidDeactivatePackage(packageData => { + if (packageData.name !== dependentPackage) { return; } + + return this.deactivate(); + }); + } + + /** + * Does the actual initialization. + */ + doActualInitialization() { + atom.workspace.observeTextEditors(editor => { + if (/text.html.php$/.test(editor.getGrammar().scopeName)) { + return this.registerEvents(editor); + } + }); + + // When you go back to only have one pane the events are lost, so need to re-register. + atom.workspace.onDidDestroyPane(pane => { + const panes = atom.workspace.getPanes(); + + if (panes.length === 1) { + return this.registerEventsForPane(panes[0]); + } + }); + + // Having to re-register events as when a new pane is created the old panes lose the events. + return atom.workspace.onDidAddPane(observedPane => { + const panes = atom.workspace.getPanes(); + + return (() => { + const result = []; + for (let pane of Array.from(panes)) { + if (pane !== observedPane) { + result.push(this.registerEventsForPane(pane)); + } else { + result.push(undefined); + } + } + return result; + })(); + }); + } + + /** + * Registers the necessary event handlers for the editors in the specified pane. + * + * @param {Pane} pane + */ + registerEventsForPane(pane) { + return (() => { + const result = []; + for (let paneItem of Array.from(pane.items)) { + if (atom.workspace.isTextEditor(paneItem)) { + if (/text.html.php$/.test(paneItem.getGrammar().scopeName)) { + result.push(this.registerEvents(paneItem)); + } else { + result.push(undefined); + } + } else { + result.push(undefined); + } + } + return result; + })(); + } + + /** + * Deactives the provider. + */ + deactivate() {} + + /** + * Retrieves intention providers (by default, the intentions menu shows when the user presses alt-enter). + * + * This method should be overwritten by subclasses. + * + * @return {array} + */ + getIntentionProviders() { + return []; + } + + /** + * Registers the necessary event handlers. + * + * @param {TextEditor} editor TextEditor to register events to. + */ + registerEvents(editor) {} + + /** + * Sets the snippet manager + * + * @param {Object} @snippetManager + */ + setSnippetManager(snippetManager) { + this.snippetManager = snippetManager; + } + + /** + * @return {Number|null} + */ + getCurrentProjectPhpVersion() { + const projectSettings = this.service.getCurrentProjectSettings(); + + if (projectSettings != null) { + return projectSettings.phpVersion; + } + + return null; + } + }; + AbstractProvider.initClass(); + return AbstractProvider; +})()); diff --git a/lib/Refactoring/ConstructorGenerationProvider.coffee b/lib/Refactoring/ConstructorGenerationProvider.coffee deleted file mode 100644 index a1025c47..00000000 --- a/lib/Refactoring/ConstructorGenerationProvider.coffee +++ /dev/null @@ -1,234 +0,0 @@ -{Point} = require 'atom' - -AbstractProvider = require './AbstractProvider' - -module.exports = - -##* -# Provides docblock generation and maintenance capabilities. -## -class DocblockProvider extends AbstractProvider - ###* - * The view that allows the user to select the properties to add to the constructor as parameters. - ### - selectionView: null - - ###* - * Aids in building functions. - ### - functionBuilder: null - - ###* - * The docblock builder. - ### - docblockBuilder: null - - ###* - * The type helper. - ### - typeHelper: null - - ###* - * @param {Object} typeHelper - * @param {Object} functionBuilder - * @param {Object} docblockBuilder - ### - constructor: (@typeHelper, @functionBuilder, @docblockBuilder) -> - - ###* - * @inheritdoc - ### - deactivate: () -> - super() - - if @selectionView - @selectionView.destroy() - @selectionView = null - - ###* - * @inheritdoc - ### - getIntentionProviders: () -> - return [{ - grammarScopes: ['source.php'] - getIntentions: ({textEditor, bufferPosition}) => - return [] if not @getCurrentProjectPhpVersion()? - - return @getIntentions(textEditor, bufferPosition) - }] - - ###* - * @param {TextEditor} editor - * @param {Point} triggerPosition - ### - getIntentions: (editor, triggerPosition) -> - successHandler = (currentClassName) => - return [] if not currentClassName? - - nestedSuccessHandler = (classInfo) => - return [] if not classInfo? - - return [{ - priority : 100 - icon : 'gear' - title : 'Generate Constructor' - - selected : () => - items = [] - promises = [] - - localTypesResolvedHandler = (results) => - resultIndex = 0 - - for item in items - for type in item.types - type.type = results[resultIndex++] - - zeroBasedStartLine = classInfo.startLine - 1 - - tabText = editor.getTabText() - indentationLevel = editor.indentationForBufferRow(triggerPosition.row) - - @generateConstructor( - editor, - triggerPosition, - items, - tabText, - indentationLevel, - atom.config.get('editor.preferredLineLength', editor.getLastCursor().getScopeDescriptor()) - ) - - if classInfo.properties.length == 0 - localTypesResolvedHandler([]) - - else - # Ensure all types are localized to the use statements of this file, the original types will be - # relative to the original file (which may not be the same). The FQCN works but is long and - # there may be a local use statement that can be used to shorten it. - for name, property of classInfo.properties - items.push({ - name : name - types : property.types - }) - - for type in property.types - if @typeHelper.isClassType(type.fqcn) - promises.push @service.localizeType( - editor.getPath(), - triggerPosition.row + 1, - type.fqcn - ) - - else - promises.push Promise.resolve(type.fqcn) - - Promise.all(promises).then(localTypesResolvedHandler, failureHandler) - }] - - return @service.getClassInfo(currentClassName).then(nestedSuccessHandler, failureHandler) - - failureHandler = () -> - return [] - - return @service.determineCurrentClassName(editor, triggerPosition).then(successHandler, failureHandler) - - ###* - * @param {TextEditor} editor - * @param {Point} triggerPosition - * @param {Array} items - * @param {String} tabText - * @param {Number} indentationLevel - * @param {Number} maxLineLength - ### - generateConstructor: (editor, triggerPosition, items, tabText, indentationLevel, maxLineLength) -> - metadata = { - editor : editor - position : triggerPosition - tabText : tabText - indentationLevel : indentationLevel - maxLineLength : maxLineLength - } - - if items.length > 0 - @getSelectionView().setItems(items) - @getSelectionView().setMetadata(metadata) - @getSelectionView().storeFocusedElement() - @getSelectionView().present() - - else - @onConfirm([], metadata) - - ###* - * Called when the selection of properties is cancelled. - * - * @param {Object|null} metadata - ### - onCancel: (metadata) -> - - ###* - * Called when the selection of properties is confirmed. - * - * @param {array} selectedItems - * @param {Object|null} metadata - ### - onConfirm: (selectedItems, metadata) -> - statements = [] - parameters = [] - docblockParameters = [] - - for item in selectedItems - typeSpecification = @typeHelper.buildTypeSpecificationFromTypeArray(item.types) - parameterTypeHint = @typeHelper.getTypeHintForTypeSpecification(typeSpecification) - - parameters.push({ - name : '$' + item.name - typeHint : parameterTypeHint.typeHint - defaultValue : if parameterTypeHint.shouldSetDefaultValueToNull then 'null' else null - }) - - docblockParameters.push({ - name : '$' + item.name - type : if item.types.length > 0 then typeSpecification else 'mixed' - }) - - statements.push("$this->#{item.name} = $#{item.name};") - - if statements.length == 0 - statements.push('') - - functionText = @functionBuilder - .makePublic() - .setIsStatic(false) - .setIsAbstract(false) - .setName('__construct') - .setReturnType(null) - .setParameters(parameters) - .setStatements(statements) - .setTabText(metadata.tabText) - .setIndentationLevel(metadata.indentationLevel) - .setMaxLineLength(metadata.maxLineLength) - .build() - - docblockText = @docblockBuilder.buildForMethod( - docblockParameters, - null, - false, - metadata.tabText.repeat(metadata.indentationLevel) - ) - - text = docblockText.trimLeft() + functionText - - metadata.editor.getBuffer().insert(metadata.position, text) - - ###* - * @return {Builder} - ### - getSelectionView: () -> - if not @selectionView? - View = require './ConstructorGenerationProvider/View' - - @selectionView = new View(@onConfirm.bind(this), @onCancel.bind(this)) - @selectionView.setLoading('Loading class information...') - @selectionView.setEmptyMessage('No properties found.') - - return @selectionView diff --git a/lib/Refactoring/ConstructorGenerationProvider.js b/lib/Refactoring/ConstructorGenerationProvider.js new file mode 100644 index 00000000..2c53b034 --- /dev/null +++ b/lib/Refactoring/ConstructorGenerationProvider.js @@ -0,0 +1,279 @@ +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS206: Consider reworking classes to avoid initClass + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let DocblockProvider; +const {Point} = require('atom'); + +const AbstractProvider = require('./AbstractProvider'); + +module.exports = + +//#* +// Provides docblock generation and maintenance capabilities. +//# +(DocblockProvider = (function() { + DocblockProvider = class DocblockProvider extends AbstractProvider { + static initClass() { + /** + * The view that allows the user to select the properties to add to the constructor as parameters. + */ + this.prototype.selectionView = null; + + /** + * Aids in building functions. + */ + this.prototype.functionBuilder = null; + + /** + * The docblock builder. + */ + this.prototype.docblockBuilder = null; + + /** + * The type helper. + */ + this.prototype.typeHelper = null; + } + + /** + * @param {Object} typeHelper + * @param {Object} functionBuilder + * @param {Object} docblockBuilder + */ + constructor(typeHelper, functionBuilder, docblockBuilder) { + super(); + + this.typeHelper = typeHelper; + this.functionBuilder = functionBuilder; + this.docblockBuilder = docblockBuilder; + } + + /** + * @inheritdoc + */ + deactivate() { + super.deactivate(); + + if (this.selectionView) { + this.selectionView.destroy(); + return this.selectionView = null; + } + } + + /** + * @inheritdoc + */ + getIntentionProviders() { + return [{ + grammarScopes: ['source.php'], + getIntentions: ({textEditor, bufferPosition}) => { + if ((this.getCurrentProjectPhpVersion() == null)) { return []; } + + return this.getIntentions(textEditor, bufferPosition); + } + }]; + } + + /** + * @param {TextEditor} editor + * @param {Point} triggerPosition + */ + getIntentions(editor, triggerPosition) { + const successHandler = currentClassName => { + if ((currentClassName == null)) { return []; } + + const nestedSuccessHandler = classInfo => { + if ((classInfo == null)) { return []; } + + return [{ + priority : 100, + icon : 'gear', + title : 'Generate Constructor', + + selected : () => { + const items = []; + const promises = []; + + const localTypesResolvedHandler = results => { + let resultIndex = 0; + + for (let item of Array.from(items)) { + for (let type of Array.from(item.types)) { + type.type = results[resultIndex++]; + } + } + + const zeroBasedStartLine = classInfo.startLine - 1; + + const tabText = editor.getTabText(); + const indentationLevel = editor.indentationForBufferRow(triggerPosition.row); + + return this.generateConstructor( + editor, + triggerPosition, + items, + tabText, + indentationLevel, + atom.config.get('editor.preferredLineLength', editor.getLastCursor().getScopeDescriptor()) + ); + }; + + if (classInfo.properties.length === 0) { + return localTypesResolvedHandler([]); + + } else { + // Ensure all types are localized to the use statements of this file, the original types will be + // relative to the original file (which may not be the same). The FQCN works but is long and + // there may be a local use statement that can be used to shorten it. + for (let name in classInfo.properties) { + const property = classInfo.properties[name]; + items.push({ + name, + types : property.types + }); + + for (let type of Array.from(property.types)) { + if (this.typeHelper.isClassType(type.fqcn)) { + promises.push(this.service.localizeType( + editor.getPath(), + triggerPosition.row + 1, + type.fqcn + ) + ); + + } else { + promises.push(Promise.resolve(type.fqcn)); + } + } + } + + return Promise.all(promises).then(localTypesResolvedHandler, failureHandler); + } + } + }]; + }; + + return this.service.getClassInfo(currentClassName).then(nestedSuccessHandler, failureHandler); + }; + + var failureHandler = () => []; + + return this.service.determineCurrentClassName(editor, triggerPosition).then(successHandler, failureHandler); + } + + /** + * @param {TextEditor} editor + * @param {Point} triggerPosition + * @param {Array} items + * @param {String} tabText + * @param {Number} indentationLevel + * @param {Number} maxLineLength + */ + generateConstructor(editor, triggerPosition, items, tabText, indentationLevel, maxLineLength) { + const metadata = { + editor, + position : triggerPosition, + tabText, + indentationLevel, + maxLineLength + }; + + if (items.length > 0) { + this.getSelectionView().setItems(items); + this.getSelectionView().setMetadata(metadata); + this.getSelectionView().storeFocusedElement(); + return this.getSelectionView().present(); + + } else { + return this.onConfirm([], metadata); + } + } + + /** + * Called when the selection of properties is cancelled. + * + * @param {Object|null} metadata + */ + onCancel(metadata) {} + + /** + * Called when the selection of properties is confirmed. + * + * @param {array} selectedItems + * @param {Object|null} metadata + */ + onConfirm(selectedItems, metadata) { + const statements = []; + const parameters = []; + const docblockParameters = []; + + for (let item of Array.from(selectedItems)) { + const typeSpecification = this.typeHelper.buildTypeSpecificationFromTypeArray(item.types); + const parameterTypeHint = this.typeHelper.getTypeHintForTypeSpecification(typeSpecification); + + parameters.push({ + name : `$${item.name}`, + typeHint : parameterTypeHint.typeHint, + defaultValue : parameterTypeHint.shouldSetDefaultValueToNull ? 'null' : null + }); + + docblockParameters.push({ + name : `$${item.name}`, + type : item.types.length > 0 ? typeSpecification : 'mixed' + }); + + statements.push(`$this->${item.name} = $${item.name};`); + } + + if (statements.length === 0) { + statements.push(''); + } + + const functionText = this.functionBuilder + .makePublic() + .setIsStatic(false) + .setIsAbstract(false) + .setName('__construct') + .setReturnType(null) + .setParameters(parameters) + .setStatements(statements) + .setTabText(metadata.tabText) + .setIndentationLevel(metadata.indentationLevel) + .setMaxLineLength(metadata.maxLineLength) + .build(); + + const docblockText = this.docblockBuilder.buildForMethod( + docblockParameters, + null, + false, + metadata.tabText.repeat(metadata.indentationLevel) + ); + + const text = docblockText.trimLeft() + functionText; + + return metadata.editor.getBuffer().insert(metadata.position, text); + } + + /** + * @return {Builder} + */ + getSelectionView() { + if ((this.selectionView == null)) { + const View = require('./ConstructorGenerationProvider/View'); + + this.selectionView = new View(this.onConfirm.bind(this), this.onCancel.bind(this)); + this.selectionView.setLoading('Loading class information...'); + this.selectionView.setEmptyMessage('No properties found.'); + } + + return this.selectionView; + } + }; + DocblockProvider.initClass(); + return DocblockProvider; +})()); diff --git a/lib/Refactoring/ConstructorGenerationProvider/View.coffee b/lib/Refactoring/ConstructorGenerationProvider/View.coffee deleted file mode 100644 index e44f4d06..00000000 --- a/lib/Refactoring/ConstructorGenerationProvider/View.coffee +++ /dev/null @@ -1,35 +0,0 @@ -{$, $$, SelectListView} = require 'atom-space-pen-views' - -MultiSelectionView = require '../Utility/MultiSelectionView.coffee' - -module.exports = - -##* -# An extension on SelectListView from atom-space-pen-views that allows multiple selections. -## -class View extends MultiSelectionView - ###* - * @inheritdoc - ### - createWidgets: () -> - checkboxBar = $$ -> - @div class: 'checkbox-bar settings-view', => - @div class: 'controls', => - @div class: 'block text-line', => - @label class: 'icon icon-info', 'Tip: The order in which items are selected determines the order of the output.' - - checkboxBar.appendTo(this) - - # Ensure that button clicks are actually handled. - @on 'mousedown', ({target}) => - return false if $(target).hasClass('checkbox-input') - return false if $(target).hasClass('checkbox-label-text') - - super() - - ###* - * @inheritdoc - ### - invokeOnDidConfirm: () -> - if @onDidConfirm - @onDidConfirm(@selectedItems, @getMetadata()) diff --git a/lib/Refactoring/ConstructorGenerationProvider/View.js b/lib/Refactoring/ConstructorGenerationProvider/View.js new file mode 100644 index 00000000..3da5d91a --- /dev/null +++ b/lib/Refactoring/ConstructorGenerationProvider/View.js @@ -0,0 +1,50 @@ +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let View; +const {$, $$, SelectListView} = require('atom-space-pen-views'); + +const MultiSelectionView = require('../Utility/MultiSelectionView'); + +module.exports = + +//#* +// An extension on SelectListView from atom-space-pen-views that allows multiple selections. +//# +(View = class View extends MultiSelectionView { + /** + * @inheritdoc + */ + createWidgets() { + const checkboxBar = $$(function() { + return this.div({class: 'checkbox-bar settings-view'}, () => { + return this.div({class: 'controls'}, () => { + return this.div({class: 'block text-line'}, () => { + return this.label({class: 'icon icon-info'}, 'Tip: The order in which items are selected determines the order of the output.'); + }); + }); + }); + }); + + checkboxBar.appendTo(this); + + // Ensure that button clicks are actually handled. + this.on('mousedown', ({target}) => { + if ($(target).hasClass('checkbox-input')) { return false; } + if ($(target).hasClass('checkbox-label-text')) { return false; } + }); + + return super.createWidgets(); + } + + /** + * @inheritdoc + */ + invokeOnDidConfirm() { + if (this.onDidConfirm) { + return this.onDidConfirm(this.selectedItems, this.getMetadata()); + } + } +}); diff --git a/lib/Refactoring/DocblockProvider.coffee b/lib/Refactoring/DocblockProvider.coffee deleted file mode 100644 index c56b7a5b..00000000 --- a/lib/Refactoring/DocblockProvider.coffee +++ /dev/null @@ -1,395 +0,0 @@ -{Point} = require 'atom' - -AbstractProvider = require './AbstractProvider' - -module.exports = - -##* -# Provides docblock generation and maintenance capabilities. -## -class DocblockProvider extends AbstractProvider - ###* - * The docblock builder. - ### - docblockBuilder: null - - ###* - * The type helper. - ### - typeHelper: null - - ###* - * @param {Object} typeHelper - * @param {Object} docblockBuilder - ### - constructor: (@typeHelper, @docblockBuilder) -> - - ###* - * @inheritdoc - ### - getIntentionProviders: () -> - return [{ - grammarScopes: ['entity.name.type.class.php', 'entity.name.type.interface.php', 'entity.name.type.trait.php'] - getIntentions: ({textEditor, bufferPosition}) => - nameRange = textEditor.bufferRangeForScopeAtCursor('entity.name.type') - - return if not nameRange? - return [] if not @getCurrentProjectPhpVersion()? - - name = textEditor.getTextInBufferRange(nameRange) - - return @getClasslikeIntentions(textEditor, bufferPosition, name) - }, { - grammarScopes: ['entity.name.function.php', 'support.function.magic.php'] - getIntentions: ({textEditor, bufferPosition}) => - nameRange = textEditor.bufferRangeForScopeAtCursor('entity.name.function.php') - - if not nameRange? - nameRange = textEditor.bufferRangeForScopeAtCursor('support.function.magic.php') - - return if not nameRange? - return [] if not @getCurrentProjectPhpVersion()? - - name = textEditor.getTextInBufferRange(nameRange) - - return @getFunctionlikeIntentions(textEditor, bufferPosition, name) - }, { - grammarScopes: ['variable.other.php'] - getIntentions: ({textEditor, bufferPosition}) => - nameRange = textEditor.bufferRangeForScopeAtCursor('variable.other.php') - - return if not nameRange? - return [] if not @getCurrentProjectPhpVersion()? - - name = textEditor.getTextInBufferRange(nameRange) - - return @getPropertyIntentions(textEditor, bufferPosition, name) - }, { - grammarScopes: ['constant.other.php'] - getIntentions: ({textEditor, bufferPosition}) => - nameRange = textEditor.bufferRangeForScopeAtCursor('constant.other.php') - - return if not nameRange? - return [] if not @getCurrentProjectPhpVersion()? - - name = textEditor.getTextInBufferRange(nameRange) - - return @getConstantIntentions(textEditor, bufferPosition, name) - }] - - ###* - * @inheritdoc - ### - deactivate: () -> - super() - - if @docblockBuilder - #@docblockBuilder.destroy() - @docblockBuilder = null - - ###* - * @param {TextEditor} editor - * @param {Point} triggerPosition - * @param {String} name - ### - getClasslikeIntentions: (editor, triggerPosition, name) -> - failureHandler = () -> - return [] - - successHandler = (resolvedType) => - nestedSuccessHandler = (classInfo) => - intentions = [] - - return intentions if not classInfo? - - if not classInfo.hasDocblock - if classInfo.hasDocumentation - intentions.push({ - priority : 100 - icon : 'gear' - title : 'Generate Docblock (inheritDoc)' - - selected : () => - @generateDocblockInheritance(editor, triggerPosition) - }) - - intentions.push({ - priority : 100 - icon : 'gear' - title : 'Generate Docblock' - - selected : () => - @generateClasslikeDocblockFor(editor, classInfo) - }) - - return intentions - - return @service.getClassInfo(resolvedType).then(nestedSuccessHandler, failureHandler) - - return @service.resolveType(editor.getPath(), triggerPosition.row + 1, name, 'classlike').then(successHandler, failureHandler) - - ###* - * @param {TextEditor} editor - * @param {Object} classData - ### - generateClasslikeDocblockFor: (editor, classData) -> - zeroBasedStartLine = classData.startLine - 1 - - indentationLevel = editor.indentationForBufferRow(zeroBasedStartLine) - - docblock = @docblockBuilder.buildByLines( - [], - editor.getTabText().repeat(indentationLevel) - ) - - editor.getBuffer().insert(new Point(zeroBasedStartLine, -1), docblock) - - ###* - * @param {TextEditor} editor - * @param {Point} triggerPosition - * @param {String} name - ### - getFunctionlikeIntentions: (editor, triggerPosition, name) -> - failureHandler = () => - return [] - - successHandler = (currentClassName) => - helperFunction = (functionlikeData) => - intentions = [] - - return intentions if not functionlikeData - - if not functionlikeData.hasDocblock - if functionlikeData.hasDocumentation - intentions.push({ - priority : 100 - icon : 'gear' - title : 'Generate Docblock (inheritDoc)' - - selected : () => - @generateDocblockInheritance(editor, triggerPosition) - }) - - intentions.push({ - priority : 100 - icon : 'gear' - title : 'Generate Docblock' - - selected : () => - @generateFunctionlikeDocblockFor(editor, functionlikeData) - }) - - return intentions - - if currentClassName - nestedSuccessHandler = (classInfo) => - return [] if not name of classInfo.methods - return helperFunction(classInfo.methods[name]) - - @service.getClassInfo(currentClassName).then(nestedSuccessHandler, failureHandler) - - else - nestedSuccessHandler = (globalFunctions) => - return [] if not name of globalFunctions - return helperFunction(globalFunctions[name]) - - @service.getGlobalFunctions().then(nestedSuccessHandler, failureHandler) - - @service.determineCurrentClassName(editor, triggerPosition).then(successHandler, failureHandler) - - ###* - * @param {TextEditor} editor - * @param {Object} data - ### - generateFunctionlikeDocblockFor: (editor, data) -> - zeroBasedStartLine = data.startLine - 1 - - parameters = data.parameters.map (parameter) => - type = 'mixed' - - if parameter.types.length > 0 - type = @typeHelper.buildTypeSpecificationFromTypeArray(parameter.types) - - name = '' - - if parameter.isReference - name += '&' - - name += '$' + parameter.name - - if parameter.isVariadic - name = '...' + name - type += '[]' - - return { - name: name - type: type - } - - indentationLevel = editor.indentationForBufferRow(zeroBasedStartLine) - - returnType = null - - if data.returnTypes.length > 0 and data.name != '__construct' - returnType = @typeHelper.buildTypeSpecificationFromTypeArray(data.returnTypes) - - docblock = @docblockBuilder.buildForMethod( - parameters, - returnType, - false, - editor.getTabText().repeat(indentationLevel) - ) - - editor.getBuffer().insert(new Point(zeroBasedStartLine, -1), docblock) - - ###* - * @param {TextEditor} editor - * @param {Point} triggerPosition - * @param {String} name - ### - getPropertyIntentions: (editor, triggerPosition, name) -> - failureHandler = () => - return [] - - successHandler = (currentClassName) => - return [] if not currentClassName? - - nestedSuccessHandler = (classInfo) => - name = name.substr(1) - - return [] if not name of classInfo.properties - - propertyData = classInfo.properties[name] - - return if not propertyData? - - intentions = [] - - return intentions if not propertyData - - if not propertyData.hasDocblock - if propertyData.hasDocumentation - intentions.push({ - priority : 100 - icon : 'gear' - title : 'Generate Docblock (inheritDoc)' - - selected : () => - @generateDocblockInheritance(editor, triggerPosition) - }) - - intentions.push({ - priority : 100 - icon : 'gear' - title : 'Generate Docblock' - - selected : () => - @generatePropertyDocblockFor(editor, propertyData) - }) - - return intentions - - @service.getClassInfo(currentClassName).then(nestedSuccessHandler, failureHandler) - - @service.determineCurrentClassName(editor, triggerPosition).then(successHandler, failureHandler) - - ###* - * @param {TextEditor} editor - * @param {Object} data - ### - generatePropertyDocblockFor: (editor, data) -> - zeroBasedStartLine = data.startLine - 1 - - indentationLevel = editor.indentationForBufferRow(zeroBasedStartLine) - - type = 'mixed' - - if data.types.length > 0 - type = @typeHelper.buildTypeSpecificationFromTypeArray(data.types) - - docblock = @docblockBuilder.buildForProperty( - type, - false, - editor.getTabText().repeat(indentationLevel) - ) - - editor.getBuffer().insert(new Point(zeroBasedStartLine, -1), docblock) - - ###* - * @param {TextEditor} editor - * @param {Point} triggerPosition - * @param {String} name - ### - getConstantIntentions: (editor, triggerPosition, name) -> - failureHandler = () => - return [] - - successHandler = (currentClassName) => - helperFunction = (constantData) => - intentions = [] - - return intentions if not constantData - - if not constantData.hasDocblock - intentions.push({ - priority : 100 - icon : 'gear' - title : 'Generate Docblock' - - selected : () => - @generateConstantDocblockFor(editor, constantData) - }) - - return intentions - - if currentClassName - nestedSuccessHandler = (classInfo) => - return [] if not name of classInfo.constants - return helperFunction(classInfo.constants[name]) - - @service.getClassInfo(currentClassName).then(nestedSuccessHandler, failureHandler) - - else - nestedSuccessHandler = (globalConstants) => - return [] if not name of globalConstants - return helperFunction(globalConstants[name]) - - @service.getGlobalConstants().then(nestedSuccessHandler, failureHandler) - - @service.determineCurrentClassName(editor, triggerPosition).then(successHandler, failureHandler) - - ###* - * @param {TextEditor} editor - * @param {Object} data - ### - generateConstantDocblockFor: (editor, data) -> - zeroBasedStartLine = data.startLine - 1 - - indentationLevel = editor.indentationForBufferRow(zeroBasedStartLine) - - type = 'mixed' - - if data.types.length > 0 - type = @typeHelper.buildTypeSpecificationFromTypeArray(data.types) - - docblock = @docblockBuilder.buildForProperty( - type, - false, - editor.getTabText().repeat(indentationLevel) - ) - - editor.getBuffer().insert(new Point(zeroBasedStartLine, -1), docblock) - - ###* - * @param {TextEditor} editor - * @param {Point} triggerPosition - ### - generateDocblockInheritance: (editor, triggerPosition) -> - indentationLevel = editor.indentationForBufferRow(triggerPosition.row) - - docblock = @docblockBuilder.buildByLines( - ['@inheritDoc'], - editor.getTabText().repeat(indentationLevel) - ) - - editor.getBuffer().insert(new Point(triggerPosition.row, -1), docblock) diff --git a/lib/Refactoring/DocblockProvider.js b/lib/Refactoring/DocblockProvider.js new file mode 100644 index 00000000..e0bc0249 --- /dev/null +++ b/lib/Refactoring/DocblockProvider.js @@ -0,0 +1,471 @@ +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * DS206: Consider reworking classes to avoid initClass + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let DocblockProvider; +const {Point} = require('atom'); + +const AbstractProvider = require('./AbstractProvider'); + +module.exports = + +//#* +// Provides docblock generation and maintenance capabilities. +//# +(DocblockProvider = (function() { + DocblockProvider = class DocblockProvider extends AbstractProvider { + static initClass() { + /** + * The docblock builder. + */ + this.prototype.docblockBuilder = null; + + /** + * The type helper. + */ + this.prototype.typeHelper = null; + } + + /** + * @param {Object} typeHelper + * @param {Object} docblockBuilder + */ + constructor(typeHelper, docblockBuilder) { + super(); + + this.typeHelper = typeHelper; + this.docblockBuilder = docblockBuilder; + } + + /** + * @inheritdoc + */ + getIntentionProviders() { + return [{ + grammarScopes: ['entity.name.type.class.php', 'entity.name.type.interface.php', 'entity.name.type.trait.php'], + getIntentions: ({textEditor, bufferPosition}) => { + const nameRange = textEditor.bufferRangeForScopeAtCursor('entity.name.type'); + + if ((nameRange == null)) { return; } + if ((this.getCurrentProjectPhpVersion() == null)) { return []; } + + const name = textEditor.getTextInBufferRange(nameRange); + + return this.getClasslikeIntentions(textEditor, bufferPosition, name); + } + }, { + grammarScopes: ['entity.name.function.php', 'support.function.magic.php'], + getIntentions: ({textEditor, bufferPosition}) => { + let nameRange = textEditor.bufferRangeForScopeAtCursor('entity.name.function.php'); + + if ((nameRange == null)) { + nameRange = textEditor.bufferRangeForScopeAtCursor('support.function.magic.php'); + } + + if ((nameRange == null)) { return; } + if ((this.getCurrentProjectPhpVersion() == null)) { return []; } + + const name = textEditor.getTextInBufferRange(nameRange); + + return this.getFunctionlikeIntentions(textEditor, bufferPosition, name); + } + }, { + grammarScopes: ['variable.other.php'], + getIntentions: ({textEditor, bufferPosition}) => { + const nameRange = textEditor.bufferRangeForScopeAtCursor('variable.other.php'); + + if ((nameRange == null)) { return; } + if ((this.getCurrentProjectPhpVersion() == null)) { return []; } + + const name = textEditor.getTextInBufferRange(nameRange); + + return this.getPropertyIntentions(textEditor, bufferPosition, name); + } + }, { + grammarScopes: ['constant.other.php'], + getIntentions: ({textEditor, bufferPosition}) => { + const nameRange = textEditor.bufferRangeForScopeAtCursor('constant.other.php'); + + if ((nameRange == null)) { return; } + if ((this.getCurrentProjectPhpVersion() == null)) { return []; } + + const name = textEditor.getTextInBufferRange(nameRange); + + return this.getConstantIntentions(textEditor, bufferPosition, name); + } + }]; + } + + /** + * @inheritdoc + */ + deactivate() { + super.deactivate(); + + if (this.docblockBuilder) { + //@docblockBuilder.destroy() + return this.docblockBuilder = null; + } + } + + /** + * @param {TextEditor} editor + * @param {Point} triggerPosition + * @param {String} name + */ + getClasslikeIntentions(editor, triggerPosition, name) { + const failureHandler = () => []; + + const successHandler = resolvedType => { + const nestedSuccessHandler = classInfo => { + const intentions = []; + + if ((classInfo == null)) { return intentions; } + + if (!classInfo.hasDocblock) { + if (classInfo.hasDocumentation) { + intentions.push({ + priority : 100, + icon : 'gear', + title : 'Generate Docblock (inheritDoc)', + + selected : () => { + return this.generateDocblockInheritance(editor, triggerPosition); + } + }); + } + + intentions.push({ + priority : 100, + icon : 'gear', + title : 'Generate Docblock', + + selected : () => { + return this.generateClasslikeDocblockFor(editor, classInfo); + } + }); + } + + return intentions; + }; + + return this.service.getClassInfo(resolvedType).then(nestedSuccessHandler, failureHandler); + }; + + return this.service.resolveType(editor.getPath(), triggerPosition.row + 1, name, 'classlike').then(successHandler, failureHandler); + } + + /** + * @param {TextEditor} editor + * @param {Object} classData + */ + generateClasslikeDocblockFor(editor, classData) { + const zeroBasedStartLine = classData.startLine - 1; + + const indentationLevel = editor.indentationForBufferRow(zeroBasedStartLine); + + const docblock = this.docblockBuilder.buildByLines( + [], + editor.getTabText().repeat(indentationLevel) + ); + + return editor.getBuffer().insert(new Point(zeroBasedStartLine, -1), docblock); + } + + /** + * @param {TextEditor} editor + * @param {Point} triggerPosition + * @param {String} name + */ + getFunctionlikeIntentions(editor, triggerPosition, name) { + const failureHandler = () => { + return []; + }; + + const successHandler = currentClassName => { + let nestedSuccessHandler; + const helperFunction = functionlikeData => { + const intentions = []; + + if (!functionlikeData) { return intentions; } + + if (!functionlikeData.hasDocblock) { + if (functionlikeData.hasDocumentation) { + intentions.push({ + priority : 100, + icon : 'gear', + title : 'Generate Docblock (inheritDoc)', + + selected : () => { + return this.generateDocblockInheritance(editor, triggerPosition); + } + }); + } + + intentions.push({ + priority : 100, + icon : 'gear', + title : 'Generate Docblock', + + selected : () => { + return this.generateFunctionlikeDocblockFor(editor, functionlikeData); + } + }); + } + + return intentions; + }; + + if (currentClassName) { + nestedSuccessHandler = classInfo => { + if (!name in classInfo.methods) { return []; } + return helperFunction(classInfo.methods[name]); + }; + + return this.service.getClassInfo(currentClassName).then(nestedSuccessHandler, failureHandler); + + } else { + nestedSuccessHandler = globalFunctions => { + if (!name in globalFunctions) { return []; } + return helperFunction(globalFunctions[name]); + }; + + return this.service.getGlobalFunctions().then(nestedSuccessHandler, failureHandler); + } + }; + + return this.service.determineCurrentClassName(editor, triggerPosition).then(successHandler, failureHandler); + } + + /** + * @param {TextEditor} editor + * @param {Object} data + */ + generateFunctionlikeDocblockFor(editor, data) { + const zeroBasedStartLine = data.startLine - 1; + + const parameters = data.parameters.map(parameter => { + let type = 'mixed'; + + if (parameter.types.length > 0) { + type = this.typeHelper.buildTypeSpecificationFromTypeArray(parameter.types); + } + + let name = ''; + + if (parameter.isReference) { + name += '&'; + } + + name += `$${parameter.name}`; + + if (parameter.isVariadic) { + name = `...${name}`; + type += '[]'; + } + + return { + name, + type + }; + }); + + const indentationLevel = editor.indentationForBufferRow(zeroBasedStartLine); + + let returnType = null; + + if ((data.returnTypes.length > 0) && (data.name !== '__construct')) { + returnType = this.typeHelper.buildTypeSpecificationFromTypeArray(data.returnTypes); + } + + const docblock = this.docblockBuilder.buildForMethod( + parameters, + returnType, + false, + editor.getTabText().repeat(indentationLevel) + ); + + return editor.getBuffer().insert(new Point(zeroBasedStartLine, -1), docblock); + } + + /** + * @param {TextEditor} editor + * @param {Point} triggerPosition + * @param {String} name + */ + getPropertyIntentions(editor, triggerPosition, name) { + const failureHandler = () => { + return []; + }; + + const successHandler = currentClassName => { + if ((currentClassName == null)) { return []; } + + const nestedSuccessHandler = classInfo => { + name = name.substr(1); + + if (!name in classInfo.properties) { return []; } + + const propertyData = classInfo.properties[name]; + + if ((propertyData == null)) { return; } + + const intentions = []; + + if (!propertyData) { return intentions; } + + if (!propertyData.hasDocblock) { + if (propertyData.hasDocumentation) { + intentions.push({ + priority : 100, + icon : 'gear', + title : 'Generate Docblock (inheritDoc)', + + selected : () => { + return this.generateDocblockInheritance(editor, triggerPosition); + } + }); + } + + intentions.push({ + priority : 100, + icon : 'gear', + title : 'Generate Docblock', + + selected : () => { + return this.generatePropertyDocblockFor(editor, propertyData); + } + }); + } + + return intentions; + }; + + return this.service.getClassInfo(currentClassName).then(nestedSuccessHandler, failureHandler); + }; + + return this.service.determineCurrentClassName(editor, triggerPosition).then(successHandler, failureHandler); + } + + /** + * @param {TextEditor} editor + * @param {Object} data + */ + generatePropertyDocblockFor(editor, data) { + const zeroBasedStartLine = data.startLine - 1; + + const indentationLevel = editor.indentationForBufferRow(zeroBasedStartLine); + + let type = 'mixed'; + + if (data.types.length > 0) { + type = this.typeHelper.buildTypeSpecificationFromTypeArray(data.types); + } + + const docblock = this.docblockBuilder.buildForProperty( + type, + false, + editor.getTabText().repeat(indentationLevel) + ); + + return editor.getBuffer().insert(new Point(zeroBasedStartLine, -1), docblock); + } + + /** + * @param {TextEditor} editor + * @param {Point} triggerPosition + * @param {String} name + */ + getConstantIntentions(editor, triggerPosition, name) { + const failureHandler = () => { + return []; + }; + + const successHandler = currentClassName => { + let nestedSuccessHandler; + const helperFunction = constantData => { + const intentions = []; + + if (!constantData) { return intentions; } + + if (!constantData.hasDocblock) { + intentions.push({ + priority : 100, + icon : 'gear', + title : 'Generate Docblock', + + selected : () => { + return this.generateConstantDocblockFor(editor, constantData); + } + }); + } + + return intentions; + }; + + if (currentClassName) { + nestedSuccessHandler = classInfo => { + if (!name in classInfo.constants) { return []; } + return helperFunction(classInfo.constants[name]); + }; + + return this.service.getClassInfo(currentClassName).then(nestedSuccessHandler, failureHandler); + + } else { + nestedSuccessHandler = globalConstants => { + if (!name in globalConstants) { return []; } + return helperFunction(globalConstants[name]); + }; + + return this.service.getGlobalConstants().then(nestedSuccessHandler, failureHandler); + } + }; + + return this.service.determineCurrentClassName(editor, triggerPosition).then(successHandler, failureHandler); + } + + /** + * @param {TextEditor} editor + * @param {Object} data + */ + generateConstantDocblockFor(editor, data) { + const zeroBasedStartLine = data.startLine - 1; + + const indentationLevel = editor.indentationForBufferRow(zeroBasedStartLine); + + let type = 'mixed'; + + if (data.types.length > 0) { + type = this.typeHelper.buildTypeSpecificationFromTypeArray(data.types); + } + + const docblock = this.docblockBuilder.buildForProperty( + type, + false, + editor.getTabText().repeat(indentationLevel) + ); + + return editor.getBuffer().insert(new Point(zeroBasedStartLine, -1), docblock); + } + + /** + * @param {TextEditor} editor + * @param {Point} triggerPosition + */ + generateDocblockInheritance(editor, triggerPosition) { + const indentationLevel = editor.indentationForBufferRow(triggerPosition.row); + + const docblock = this.docblockBuilder.buildByLines( + ['@inheritDoc'], + editor.getTabText().repeat(indentationLevel) + ); + + return editor.getBuffer().insert(new Point(triggerPosition.row, -1), docblock); + } + }; + DocblockProvider.initClass(); + return DocblockProvider; +})()); diff --git a/lib/Refactoring/ExtractMethodProvider.coffee b/lib/Refactoring/ExtractMethodProvider.coffee deleted file mode 100644 index 48cf5ce3..00000000 --- a/lib/Refactoring/ExtractMethodProvider.coffee +++ /dev/null @@ -1,330 +0,0 @@ -{Range} = require 'atom' - -AbstractProvider = require './AbstractProvider' - -module.exports = - -##* -# Provides method extraction capabilities. -## -class ExtractMethodProvider extends AbstractProvider - ###* - * View that the user interacts with when extracting code. - * - * @type {Object} - ### - view: null - - ###* - * Builder used to generate the new method. - * - * @type {Object} - ### - builder: null - - ###* - * @param {Object} builder - ### - constructor: (@builder) -> - - ###* - * @inheritdoc - ### - activate: (service) -> - super(service) - - atom.commands.add 'atom-text-editor', "php-ide-serenata:extract-method": => - @executeCommand() - - ###* - * @inheritdoc - ### - deactivate: () -> - super() - - if @view - @view.destroy() - @view = null - - ###* - * @inheritdoc - ### - getIntentionProviders: () -> - return [{ - grammarScopes: ['source.php'] - getIntentions: ({textEditor, bufferPosition}) => - activeTextEditor = atom.workspace.getActiveTextEditor() - - return [] if not activeTextEditor - return [] if not @getCurrentProjectPhpVersion()? - - selection = activeTextEditor.getSelectedBufferRange() - - # Checking if a selection has been made - if selection.start.row == selection.end.row and selection.start.column == selection.end.column - return [] - - return [ - { - priority : 200 - icon : 'git-branch' - title : 'Extract Method' - - selected : () => - @executeCommand() - } - ] - }] - - ###* - * Executes the extraction. - ### - executeCommand: () -> - activeTextEditor = atom.workspace.getActiveTextEditor() - - return if not activeTextEditor - - tabText = activeTextEditor.getTabText() - - selection = activeTextEditor.getSelectedBufferRange() - - # Checking if a selection has been made - if selection.start.row == selection.end.row and selection.start.column == selection.end.column - atom.notifications.addInfo('Serenata', { - detail: 'Please select the code to extract and try again.' - }) - - return - - line = activeTextEditor.lineTextForBufferRow(selection.start.row) - - findSingleTab = new RegExp("(#{tabText})", "g") - - matches = (line.match(findSingleTab) || []).length - - # If the first line doesn't have any tabs then add one. - highlightedText = activeTextEditor.getTextInBufferRange(selection) - selectedBufferFirstLine = highlightedText.split("\n")[0] - - if (selectedBufferFirstLine.match(findSingleTab) || []).length == 0 - highlightedText = "#{tabText}" + highlightedText - - # Replacing double indents with one, so it can be shown in the preview area of panel. - multipleTabTexts = Array(matches).fill("#{tabText}") - findMultipleTab = new RegExp("^" + multipleTabTexts.join(''), "mg") - reducedHighlightedText = highlightedText.replace(findMultipleTab, "#{tabText}") - - @builder.setEditor(activeTextEditor) - @builder.setMethodBody(reducedHighlightedText) - - @getView().storeFocusedElement() - @getView().present() - - ###* - * Called when the user has cancel the extraction in the modal. - ### - onCancel: -> - @builder.cleanUp() - - ###* - * Called when the user has confirmed the extraction in the modal. - * - * @param {Object} settings - * - * @see ParameterParser.buildMethod for structure of settings - ### - onConfirm: (settings) -> - successHandler = (methodCall) => - activeTextEditor = atom.workspace.getActiveTextEditor() - - selectedBufferRange = activeTextEditor.getSelectedBufferRange() - - highlightedBufferPosition = selectedBufferRange.end - - row = 0 - - loop - row++ - descriptions = activeTextEditor.scopeDescriptorForBufferPosition( - [highlightedBufferPosition.row + row, activeTextEditor.getTabLength()] - ) - indexOfDescriptor = descriptions.scopes.indexOf('punctuation.section.scope.end.php') - break if indexOfDescriptor > -1 || row == activeTextEditor.getLineCount() - - row = highlightedBufferPosition.row + row - - line = activeTextEditor.lineTextForBufferRow row - - endOfLine = line?.length - - replaceRange = [ - [row, 0], - [row, endOfLine] - ] - - previousText = activeTextEditor.getTextInBufferRange replaceRange - - settings.tabs = true - - nestedSuccessHandler = (newMethodBody) => - settings.tabs = false - - @builder.cleanUp() - - activeTextEditor.transact () => - # Matching current indentation - selectedText = activeTextEditor.getSelectedText() - spacing = selectedText.match /^\s*/ - if spacing != null - spacing = spacing[0] - - activeTextEditor.insertText(spacing + methodCall) - - # Remove any extra new lines between functions - nextLine = activeTextEditor.lineTextForBufferRow row + 1 - if nextLine == '' - activeTextEditor.setSelectedBufferRange( - [ - [row + 1, 0], - [row + 1, 1] - ] - ) - activeTextEditor.deleteLine() - - - # Re working out range as inserting method call will delete some - # lines and thus offsetting this - row -= selectedBufferRange.end.row - selectedBufferRange.start.row - - if @snippetManager? - activeTextEditor.setCursorBufferPosition [row + 1, 0] - - body = "\n#{newMethodBody}" - - result = @getTabStopsForBody body - - snippet = { - body: body, - lineCount: result.lineCount, - tabStops: result.tabStops - } - - @snippetManager.insertSnippet( - snippet, - activeTextEditor - ) - else - # Re working out range as inserting method call will delete some - # lines and thus offsetting this - row -= selectedBufferRange.end.row - selectedBufferRange.start.row - - replaceRange = [ - [row, 0], - [row, line?.length] - ] - - activeTextEditor.setTextInBufferRange( - replaceRange, - "#{previousText}\n\n#{newMethodBody}" - ) - - nestedFailureHandler = () => - settings.tabs = false - - @builder.buildMethod(settings).then(nestedSuccessHandler, nestedFailureHandler) - - failureHandler = () => - # Do nothing. - - @builder.buildMethodCall(settings.methodName).then(successHandler, failureHandler) - - ###* - * Gets all the tab stops and line count for the body given - * - * @param {String} body - * - * @return {Object} - ### - getTabStopsForBody: (body) -> - lines = body.split "\n" - row = 0 - lineCount = 0 - tabStops = [] - tabStopIndex = {} - - for line in lines - regex = /(\[[\w ]*?\])(\s*\$[a-zA-Z0-9_]+)?/g - # Get tab stops by looping through all matches - while (match = regex.exec(line)) != null - key = match[2] # 2nd capturing group (variable name) - replace = match[1] # 1st capturing group ([type]) - range = new Range( - [row, match.index], - [row, match.index + match[1].length] - ) - - if key != undefined - key = key.trim() - if tabStopIndex[key] != undefined - tabStopIndex[key].push range - else - tabStopIndex[key] = [range] - else - tabStops.push [range] - - row++ - lineCount++ - - for objectKey in Object.keys(tabStopIndex) - tabStops.push tabStopIndex[objectKey] - - tabStops = tabStops.sort @sortTabStops - - return { - tabStops: tabStops, - lineCount: lineCount - } - - ###* - * Sorts the tab stops by their row and column - * - * @param {Array} a - * @param {Array} b - * - * @return {Integer} - ### - sortTabStops: (a, b) -> - # Grabbing first range in the array - a = a[0] - b = b[0] - - # b is before a in the rows - if a.start.row > b.start.row - return 1 - - # a is before b in the rows - if a.start.row < b.start.row - return -1 - - # On same line but b is before a - if a.start.column > b.start.column - return 1 - - # On same line but a is before b - if a.start.column < b.start.column - return -1 - - # Same position - return 0 - - ###* - * @return {View} - ### - getView: () -> - if not @view? - View = require './ExtractMethodProvider/View' - - @view = new View(@onConfirm.bind(this), @onCancel.bind(this)) - @view.setBuilder(@builder) - - return @view diff --git a/lib/Refactoring/ExtractMethodProvider.js b/lib/Refactoring/ExtractMethodProvider.js new file mode 100644 index 00000000..19b45745 --- /dev/null +++ b/lib/Refactoring/ExtractMethodProvider.js @@ -0,0 +1,391 @@ +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS206: Consider reworking classes to avoid initClass + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let ExtractMethodProvider; +const {Range} = require('atom'); + +const AbstractProvider = require('./AbstractProvider'); + +module.exports = + +//#* +// Provides method extraction capabilities. +//# +(ExtractMethodProvider = (function() { + ExtractMethodProvider = class ExtractMethodProvider extends AbstractProvider { + static initClass() { + /** + * View that the user interacts with when extracting code. + * + * @type {Object} + */ + this.prototype.view = null; + + /** + * Builder used to generate the new method. + * + * @type {Object} + */ + this.prototype.builder = null; + } + + /** + * @param {Object} builder + */ + constructor(builder) { + super(); + + this.builder = builder; + } + + /** + * @inheritdoc + */ + activate(service) { + super.activate(service); + + return atom.commands.add('atom-text-editor', { "php-ide-serenata:extract-method": () => { + return this.executeCommand(); + } + } + ); + } + + /** + * @inheritdoc + */ + deactivate() { + super.deactivate(); + + if (this.view) { + this.view.destroy(); + return this.view = null; + } + } + + /** + * @inheritdoc + */ + getIntentionProviders() { + return [{ + grammarScopes: ['source.php'], + getIntentions: ({textEditor, bufferPosition}) => { + const activeTextEditor = atom.workspace.getActiveTextEditor(); + + if (!activeTextEditor) { return []; } + if ((this.getCurrentProjectPhpVersion() == null)) { return []; } + + const selection = activeTextEditor.getSelectedBufferRange(); + + // Checking if a selection has been made + if ((selection.start.row === selection.end.row) && (selection.start.column === selection.end.column)) { + return []; + } + + return [ + { + priority : 200, + icon : 'git-branch', + title : 'Extract Method', + + selected : () => { + return this.executeCommand(); + } + } + ]; + } + }]; + } + + /** + * Executes the extraction. + */ + executeCommand() { + const activeTextEditor = atom.workspace.getActiveTextEditor(); + + if (!activeTextEditor) { return; } + + const tabText = activeTextEditor.getTabText(); + + const selection = activeTextEditor.getSelectedBufferRange(); + + // Checking if a selection has been made + if ((selection.start.row === selection.end.row) && (selection.start.column === selection.end.column)) { + atom.notifications.addInfo('Serenata', { + detail: 'Please select the code to extract and try again.' + }); + + return; + } + + const line = activeTextEditor.lineTextForBufferRow(selection.start.row); + + const findSingleTab = new RegExp(`(${tabText})`, "g"); + + const matches = (line.match(findSingleTab) || []).length; + + // If the first line doesn't have any tabs then add one. + let highlightedText = activeTextEditor.getTextInBufferRange(selection); + const selectedBufferFirstLine = highlightedText.split("\n")[0]; + + if ((selectedBufferFirstLine.match(findSingleTab) || []).length === 0) { + highlightedText = `${tabText}` + highlightedText; + } + + // Replacing double indents with one, so it can be shown in the preview area of panel. + const multipleTabTexts = Array(matches).fill(`${tabText}`); + const findMultipleTab = new RegExp(`^${multipleTabTexts.join('')}`, "mg"); + const reducedHighlightedText = highlightedText.replace(findMultipleTab, `${tabText}`); + + this.builder.setEditor(activeTextEditor); + this.builder.setMethodBody(reducedHighlightedText); + + this.getView().storeFocusedElement(); + return this.getView().present(); + } + + /** + * Called when the user has cancel the extraction in the modal. + */ + onCancel() { + return this.builder.cleanUp(); + } + + /** + * Called when the user has confirmed the extraction in the modal. + * + * @param {Object} settings + * + * @see ParameterParser.buildMethod for structure of settings + */ + onConfirm(settings) { + const successHandler = methodCall => { + const activeTextEditor = atom.workspace.getActiveTextEditor(); + + const selectedBufferRange = activeTextEditor.getSelectedBufferRange(); + + const highlightedBufferPosition = selectedBufferRange.end; + + let row = 0; + + while (true) { + row++; + const descriptions = activeTextEditor.scopeDescriptorForBufferPosition( + [highlightedBufferPosition.row + row, activeTextEditor.getTabLength()] + ); + const indexOfDescriptor = descriptions.scopes.indexOf('punctuation.section.scope.end.php'); + if ((indexOfDescriptor > -1) || (row === activeTextEditor.getLineCount())) { break; } + } + + row = highlightedBufferPosition.row + row; + + const line = activeTextEditor.lineTextForBufferRow(row); + + const endOfLine = line != null ? line.length : undefined; + + let replaceRange = [ + [row, 0], + [row, endOfLine] + ]; + + const previousText = activeTextEditor.getTextInBufferRange(replaceRange); + + settings.tabs = true; + + const nestedSuccessHandler = newMethodBody => { + settings.tabs = false; + + this.builder.cleanUp(); + + return activeTextEditor.transact(() => { + // Matching current indentation + const selectedText = activeTextEditor.getSelectedText(); + let spacing = selectedText.match(/^\s*/); + if (spacing !== null) { + spacing = spacing[0]; + } + + activeTextEditor.insertText(spacing + methodCall); + + // Remove any extra new lines between functions + const nextLine = activeTextEditor.lineTextForBufferRow(row + 1); + if (nextLine === '') { + activeTextEditor.setSelectedBufferRange( + [ + [row + 1, 0], + [row + 1, 1] + ] + ); + activeTextEditor.deleteLine(); + } + + + // Re working out range as inserting method call will delete some + // lines and thus offsetting this + row -= selectedBufferRange.end.row - selectedBufferRange.start.row; + + if (this.snippetManager != null) { + activeTextEditor.setCursorBufferPosition([row + 1, 0]); + + const body = `\n${newMethodBody}`; + + const result = this.getTabStopsForBody(body); + + const snippet = { + body, + lineCount: result.lineCount, + tabStopList: Array.from(result.tabStops) + }; + + snippet.tabStopList.toArray = () => { + return snippet.tabStopList; + }; + + return this.snippetManager.insertSnippet( + snippet, + activeTextEditor + ); + } else { + // Re working out range as inserting method call will delete some + // lines and thus offsetting this + row -= selectedBufferRange.end.row - selectedBufferRange.start.row; + + replaceRange = [ + [row, 0], + [row, (line != null ? line.length : undefined)] + ]; + + return activeTextEditor.setTextInBufferRange( + replaceRange, + `${previousText}\n\n${newMethodBody}` + ); + } + }); + }; + + const nestedFailureHandler = () => { + return settings.tabs = false; + }; + + return this.builder.buildMethod(settings).then(nestedSuccessHandler, nestedFailureHandler); + }; + + const failureHandler = () => {}; + // Do nothing. + + return this.builder.buildMethodCall(settings.methodName).then(successHandler, failureHandler); + } + + /** + * Gets all the tab stops and line count for the body given + * + * @param {String} body + * + * @return {Object} + */ + getTabStopsForBody(body) { + const lines = body.split("\n"); + let row = 0; + let lineCount = 0; + let tabStops = []; + const tabStopIndex = {}; + + for (let line of Array.from(lines)) { + var match; + const regex = /(\[[\w ]*?\])(\s*\$[a-zA-Z0-9_]+)?/g; + // Get tab stops by looping through all matches + while ((match = regex.exec(line)) !== null) { + let key = match[2]; // 2nd capturing group (variable name) + const replace = match[1]; // 1st capturing group ([type]) + const range = new Range( + [row, match.index], + [row, match.index + match[1].length] + ); + + if (key !== undefined) { + key = key.trim(); + if (tabStopIndex[key] !== undefined) { + tabStopIndex[key].push(range); + } else { + tabStopIndex[key] = [range]; + } + } else { + tabStops.push([range]); + } + } + + row++; + lineCount++; + } + + for (let objectKey of Array.from(Object.keys(tabStopIndex))) { + tabStops.push(tabStopIndex[objectKey]); + } + + tabStops = tabStops.sort(this.sortTabStops); + + return { + tabStops, + lineCount + }; + } + + /** + * Sorts the tab stops by their row and column + * + * @param {Array} a + * @param {Array} b + * + * @return {Integer} + */ + sortTabStops(a, b) { + // Grabbing first range in the array + a = a[0]; + b = b[0]; + + // b is before a in the rows + if (a.start.row > b.start.row) { + return 1; + } + + // a is before b in the rows + if (a.start.row < b.start.row) { + return -1; + } + + // On same line but b is before a + if (a.start.column > b.start.column) { + return 1; + } + + // On same line but a is before b + if (a.start.column < b.start.column) { + return -1; + } + + // Same position + return 0; + } + + /** + * @return {View} + */ + getView() { + if ((this.view == null)) { + const View = require('./ExtractMethodProvider/View'); + + this.view = new View(this.onConfirm.bind(this), this.onCancel.bind(this)); + this.view.setBuilder(this.builder); + } + + return this.view; + } + }; + ExtractMethodProvider.initClass(); + return ExtractMethodProvider; +})()); diff --git a/lib/Refactoring/ExtractMethodProvider/Builder.coffee b/lib/Refactoring/ExtractMethodProvider/Builder.coffee deleted file mode 100644 index 0a7fbb25..00000000 --- a/lib/Refactoring/ExtractMethodProvider/Builder.coffee +++ /dev/null @@ -1,374 +0,0 @@ -{Range} = require 'atom' - -module.exports = - -class Builder - ###* - * The body of the new method that will be shown in the preview area. - * - * @type {String} - ### - methodBody: '' - - ###* - * The tab string that is used by the current editor. - * - * @type {String} - ### - tabText: '' - - ###* - * @type {Number} - ### - indentationLevel: null - - ###* - * @type {Number} - ### - maxLineLength: null - - ###* - * The php-ide-serenata service. - * - * @type {Service} - ### - service: null - - ###* - * A range of the selected/highlighted area of code to analyse. - * - * @type {Range} - ### - selectedBufferRange: null - - ###* - * The text editor to be analysing. - * - * @type {TextEditor} - ### - editor: null - - ###* - * The parameter parser that will work out the parameters the - * selectedBufferRange will need. - * - * @type {Object} - ### - parameterParser: null - - ###* - * All the variables to return - * - * @type {Array} - ### - returnVariables: null - - ###* - * @type {Object} - ### - docblockBuilder: null - - ###* - * @type {Object} - ### - functionBuilder: null - - ###* - * @type {Object} - ### - typeHelper: null - - ###* - * Constructor. - * - * @param {Object} parameterParser - * @param {Object} docblockBuilder - * @param {Object} functionBuilder - * @param {Object} typeHelper - ### - constructor: (@parameterParser, @docblockBuilder, @functionBuilder, @typeHelper) -> - - ###* - * Sets the method body to use in the preview. - * - * @param {String} text - ### - setMethodBody: (text) -> - @methodBody = text - - ###* - * The tab string to use when generating the new method. - * - * @param {String} tab - ### - setTabText: (tab) -> - @tabText = tab - - ###* - * @param {Number} indentationLevel - ### - setIndentationLevel: (indentationLevel) -> - @indentationLevel = indentationLevel - - ###* - * @param {Number} maxLineLength - ### - setMaxLineLength: (maxLineLength) -> - @maxLineLength = maxLineLength - - ###* - * Set the php-ide-serenata service to be used. - * - * @param {Service} service - ### - setService: (service) -> - @service = service - @parameterParser.setService(service) - - ###* - * Set the selectedBufferRange to analyse. - * - * @param {Range} range [description] - ### - setSelectedBufferRange: (range) -> - @selectedBufferRange = range - - ###* - * Set the TextEditor to be used when analysing the selectedBufferRange - * - * @param {TextEditor} editor [description] - ### - setEditor: (editor) => - @editor = editor - @setTabText(editor.getTabText()) - @setIndentationLevel(1) - @setMaxLineLength(atom.config.get('editor.preferredLineLength', editor.getLastCursor().getScopeDescriptor())) - @setSelectedBufferRange(editor.getSelectedBufferRange()) - - ###* - * Builds the new method from the selectedBufferRange and settings given. - * - * The settings parameter should be an object with these properties: - * - methodName (string) - * - visibility (string) ['private', 'protected', 'public'] - * - tabs (boolean) - * - generateDocs (boolean) - * - arraySyntax (string) ['word', 'brackets'] - * - generateDocPlaceholders (boolean) - * - * @param {Object} settings - * - * @return {Promise} - ### - buildMethod: (settings) => - successHandler = (parameters) => - if @returnVariables == null - @returnVariables = @workOutReturnVariables @parameterParser.getVariableDeclarations() - - tabText = if settings.tabs then @tabText else '' - totalIndentation = tabText.repeat(@indentationLevel) - - statements = [] - - for statement in @methodBody.split('\n') - newStatement = statement.substr(totalIndentation.length) - - statements.push(newStatement) - - returnTypeHintSpecification = 'void' - returnStatement = @buildReturnStatement(@returnVariables, settings.arraySyntax) - - if returnStatement? - if @returnVariables.length == 1 - returnTypeHintSpecification = @returnVariables[0].types.join('|') - - else - returnTypeHintSpecification = 'array' - - returnStatement = returnStatement.substr(totalIndentation.length) - - statements.push('') - statements.push(returnStatement) - - functionParameters = parameters.map (parameter) => - typeHintInfo = @typeHelper.getTypeHintForDocblockTypes(parameter.types) - - return { - name : parameter.name - typeHint : if typeHintInfo? and settings.typeHinting then typeHintInfo.typeHint else null - defaultValue : if typeHintInfo? and typeHintInfo.isNullable then 'null' else null - } - - docblockParameters = parameters.map (parameter) => - typeSpecification = @typeHelper.buildTypeSpecificationFromTypes(parameter.types) - - return { - name : parameter.name - type : if typeSpecification.length > 0 then typeSpecification else '[type]' - } - - @functionBuilder - .setIsStatic(false) - .setIsAbstract(false) - .setName(settings.methodName) - .setReturnType(@typeHelper.getReturnTypeHintForTypeSpecification(returnTypeHintSpecification)) - .setParameters(functionParameters) - .setStatements(statements) - .setIndentationLevel(@indentationLevel) - .setTabText(tabText) - .setMaxLineLength(@maxLineLength) - - if settings.visibility == 'public' - @functionBuilder.makePublic() - - else if settings.visibility == 'protected' - @functionBuilder.makeProtected() - - else if settings.visibility == 'private' - @functionBuilder.makePrivate() - - else - @functionBuilder.makeGlobal() - - docblockText = '' - - if settings.generateDocs - returnType = 'void' - - if @returnVariables != null && @returnVariables.length > 0 - returnType = '[type]' - - if @returnVariables.length > 1 - returnType = 'array' - - else if @returnVariables.length == 1 and @returnVariables[0].types.length > 0 - returnType = @typeHelper.buildTypeSpecificationFromTypes(@returnVariables[0].types) - - docblockText = @docblockBuilder.buildForMethod( - docblockParameters, - returnType, - settings.generateDescPlaceholders, - totalIndentation - ) - - return docblockText + @functionBuilder.build() - - failureHandler = () -> - return null - - return @parameterParser.findParameters(@editor, @selectedBufferRange).then(successHandler, failureHandler) - - ###* - * Build the line that calls the new method and the variable the method - * to be assigned to. - * - * @param {String} methodName - * @param {String} variable [Optional] - * - * @return {Promise} - ### - buildMethodCall: (methodName, variable) => - successHandler = (parameters) => - parameterNames = parameters.map (item) -> - return item.name - - methodCall = "$this->#{methodName}(#{parameterNames.join ', '});" - - if variable != undefined - methodCall = "$#{variable} = #{methodCall}" - else - if @returnVariables != null - if @returnVariables.length == 1 - methodCall = "#{@returnVariables[0].name} = #{methodCall}" - else if @returnVariables.length > 1 - variables = @returnVariables.reduce (previous, current) -> - if typeof previous != 'string' - previous = previous.name - - return previous + ', ' + current.name - - methodCall = "list(#{variables}) = #{methodCall}" - - return methodCall - - failureHandler = () -> - return null - - @parameterParser.findParameters(@editor, @selectedBufferRange).then(successHandler, failureHandler) - - ###* - * Performs any clean up needed with the builder. - ### - cleanUp: -> - @returnVariables = null - @parameterParser.cleanUp() - - ###* - * Works out which variables need to be returned from the new method. - * - * @param {Array} variableDeclarations - * - * @return {Array} - ### - workOutReturnVariables: (variableDeclarations) -> - startPoint = @selectedBufferRange.end - scopeRange = @parameterParser.getRangeForCurrentScope(@editor, startPoint) - - lookupRange = new Range(startPoint, scopeRange.end) - - textAfterExtraction = @editor.getTextInBufferRange lookupRange - allVariablesAfterExtraction = textAfterExtraction.match /\$[a-zA-Z0-9]+/g - - return null if allVariablesAfterExtraction == null - - variableDeclarations = variableDeclarations.filter (variable) => - return true if variable.name in allVariablesAfterExtraction - return false - - return variableDeclarations - - ###* - * Builds the return statement for the new method. - * - * @param {Array} variableDeclarations - * @param {String} arrayType ['word', 'brackets'] - * - * @return {String|null} - ### - buildReturnStatement: (variableDeclarations, arrayType = 'word') -> - if variableDeclarations? - if variableDeclarations.length == 1 - return "#{@tabText}return #{variableDeclarations[0].name};" - - else if variableDeclarations.length > 1 - variables = variableDeclarations.reduce (previous, current) -> - if typeof previous != 'string' - previous = previous.name - - return previous + ', ' + current.name - - if arrayType == 'brackets' - variables = "[#{variables}]" - - else - variables = "array(#{variables})" - - return "#{@tabText}return #{variables};" - - return null - - ###* - * Checks if the new method will be returning any values. - * - * @return {Boolean} - ### - hasReturnValues: -> - return @returnVariables != null && @returnVariables.length > 0 - - ###* - * Returns if there are multiple return values. - * - * @return {Boolean} - ### - hasMultipleReturnValues: -> - return @returnVariables != null && @returnVariables.length > 1 diff --git a/lib/Refactoring/ExtractMethodProvider/Builder.js b/lib/Refactoring/ExtractMethodProvider/Builder.js new file mode 100644 index 00000000..9b5d2f90 --- /dev/null +++ b/lib/Refactoring/ExtractMethodProvider/Builder.js @@ -0,0 +1,433 @@ +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS206: Consider reworking classes to avoid initClass + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let Builder; +const {Range} = require('atom'); + +module.exports = + +(Builder = (function() { + Builder = class Builder { + static initClass() { + /** + * The body of the new method that will be shown in the preview area. + * + * @type {String} + */ + this.prototype.methodBody = ''; + + /** + * The tab string that is used by the current editor. + * + * @type {String} + */ + this.prototype.tabText = ''; + + /** + * @type {Number} + */ + this.prototype.indentationLevel = null; + + /** + * @type {Number} + */ + this.prototype.maxLineLength = null; + + /** + * The php-ide-serenata service. + * + * @type {Service} + */ + this.prototype.service = null; + + /** + * A range of the selected/highlighted area of code to analyse. + * + * @type {Range} + */ + this.prototype.selectedBufferRange = null; + + /** + * The text editor to be analysing. + * + * @type {TextEditor} + */ + this.prototype.editor = null; + + /** + * The parameter parser that will work out the parameters the + * selectedBufferRange will need. + * + * @type {Object} + */ + this.prototype.parameterParser = null; + + /** + * All the variables to return + * + * @type {Array} + */ + this.prototype.returnVariables = null; + + /** + * @type {Object} + */ + this.prototype.docblockBuilder = null; + + /** + * @type {Object} + */ + this.prototype.functionBuilder = null; + + /** + * @type {Object} + */ + this.prototype.typeHelper = null; + } + + /** + * Constructor. + * + * @param {Object} parameterParser + * @param {Object} docblockBuilder + * @param {Object} functionBuilder + * @param {Object} typeHelper + */ + constructor(parameterParser, docblockBuilder, functionBuilder, typeHelper) { + this.setEditor = this.setEditor.bind(this); + this.buildMethod = this.buildMethod.bind(this); + this.buildMethodCall = this.buildMethodCall.bind(this); + this.parameterParser = parameterParser; + this.docblockBuilder = docblockBuilder; + this.functionBuilder = functionBuilder; + this.typeHelper = typeHelper; + } + + /** + * Sets the method body to use in the preview. + * + * @param {String} text + */ + setMethodBody(text) { + return this.methodBody = text; + } + + /** + * The tab string to use when generating the new method. + * + * @param {String} tab + */ + setTabText(tab) { + return this.tabText = tab; + } + + /** + * @param {Number} indentationLevel + */ + setIndentationLevel(indentationLevel) { + return this.indentationLevel = indentationLevel; + } + + /** + * @param {Number} maxLineLength + */ + setMaxLineLength(maxLineLength) { + return this.maxLineLength = maxLineLength; + } + + /** + * Set the php-ide-serenata service to be used. + * + * @param {Service} service + */ + setService(service) { + this.service = service; + return this.parameterParser.setService(service); + } + + /** + * Set the selectedBufferRange to analyse. + * + * @param {Range} range [description] + */ + setSelectedBufferRange(range) { + return this.selectedBufferRange = range; + } + + /** + * Set the TextEditor to be used when analysing the selectedBufferRange + * + * @param {TextEditor} editor [description] + */ + setEditor(editor) { + this.editor = editor; + this.setTabText(editor.getTabText()); + this.setIndentationLevel(1); + this.setMaxLineLength(atom.config.get('editor.preferredLineLength', editor.getLastCursor().getScopeDescriptor())); + return this.setSelectedBufferRange(editor.getSelectedBufferRange()); + } + + /** + * Builds the new method from the selectedBufferRange and settings given. + * + * The settings parameter should be an object with these properties: + * - methodName (string) + * - visibility (string) ['private', 'protected', 'public'] + * - tabs (boolean) + * - generateDocs (boolean) + * - arraySyntax (string) ['word', 'brackets'] + * - generateDocPlaceholders (boolean) + * + * @param {Object} settings + * + * @return {Promise} + */ + buildMethod(settings) { + const successHandler = parameters => { + if (this.returnVariables === null) { + this.returnVariables = this.workOutReturnVariables(this.parameterParser.getVariableDeclarations()); + } + + const tabText = settings.tabs ? this.tabText : ''; + const totalIndentation = tabText.repeat(this.indentationLevel); + + const statements = []; + + for (let statement of Array.from(this.methodBody.split('\n'))) { + const newStatement = statement.substr(totalIndentation.length); + + statements.push(newStatement); + } + + let returnTypeHintSpecification = 'void'; + let returnStatement = this.buildReturnStatement(this.returnVariables, settings.arraySyntax); + + if (returnStatement != null) { + if (this.returnVariables.length === 1) { + returnTypeHintSpecification = this.returnVariables[0].types.join('|'); + + } else { + returnTypeHintSpecification = 'array'; + } + + returnStatement = returnStatement.substr(totalIndentation.length); + + statements.push(''); + statements.push(returnStatement); + } + + const functionParameters = parameters.map(parameter => { + const typeHintInfo = this.typeHelper.getTypeHintForDocblockTypes(parameter.types); + + return { + name : parameter.name, + typeHint : (typeHintInfo != null) && settings.typeHinting ? typeHintInfo.typeHint : null, + defaultValue : (typeHintInfo != null) && typeHintInfo.isNullable ? 'null' : null + }; + }); + + const docblockParameters = parameters.map(parameter => { + const typeSpecification = this.typeHelper.buildTypeSpecificationFromTypes(parameter.types); + + return { + name : parameter.name, + type : typeSpecification.length > 0 ? typeSpecification : '[type]' + }; + }); + + this.functionBuilder + .setIsStatic(false) + .setIsAbstract(false) + .setName(settings.methodName) + .setReturnType(this.typeHelper.getReturnTypeHintForTypeSpecification(returnTypeHintSpecification)) + .setParameters(functionParameters) + .setStatements(statements) + .setIndentationLevel(this.indentationLevel) + .setTabText(tabText) + .setMaxLineLength(this.maxLineLength); + + if (settings.visibility === 'public') { + this.functionBuilder.makePublic(); + + } else if (settings.visibility === 'protected') { + this.functionBuilder.makeProtected(); + + } else if (settings.visibility === 'private') { + this.functionBuilder.makePrivate(); + + } else { + this.functionBuilder.makeGlobal(); + } + + let docblockText = ''; + + if (settings.generateDocs) { + let returnType = 'void'; + + if ((this.returnVariables !== null) && (this.returnVariables.length > 0)) { + returnType = '[type]'; + + if (this.returnVariables.length > 1) { + returnType = 'array'; + + } else if ((this.returnVariables.length === 1) && (this.returnVariables[0].types.length > 0)) { + returnType = this.typeHelper.buildTypeSpecificationFromTypes(this.returnVariables[0].types); + } + } + + docblockText = this.docblockBuilder.buildForMethod( + docblockParameters, + returnType, + settings.generateDescPlaceholders, + totalIndentation + ); + } + + return docblockText + this.functionBuilder.build(); + }; + + const failureHandler = () => null; + + return this.parameterParser.findParameters(this.editor, this.selectedBufferRange).then(successHandler, failureHandler); + } + + /** + * Build the line that calls the new method and the variable the method + * to be assigned to. + * + * @param {String} methodName + * @param {String} variable [Optional] + * + * @return {Promise} + */ + buildMethodCall(methodName, variable) { + const successHandler = parameters => { + const parameterNames = parameters.map(item => item.name); + + let methodCall = `$this->${methodName}(${parameterNames.join(', ')});`; + + if (variable !== undefined) { + methodCall = `$${variable} = ${methodCall}`; + } else { + if (this.returnVariables !== null) { + if (this.returnVariables.length === 1) { + methodCall = `${this.returnVariables[0].name} = ${methodCall}`; + } else if (this.returnVariables.length > 1) { + const variables = this.returnVariables.reduce(function(previous, current) { + if (typeof previous !== 'string') { + previous = previous.name; + } + + return previous + ', ' + current.name; + }); + + methodCall = `list(${variables}) = ${methodCall}`; + } + } + } + + return methodCall; + }; + + const failureHandler = () => null; + + return this.parameterParser.findParameters(this.editor, this.selectedBufferRange).then(successHandler, failureHandler); + } + + /** + * Performs any clean up needed with the builder. + */ + cleanUp() { + this.returnVariables = null; + return this.parameterParser.cleanUp(); + } + + /** + * Works out which variables need to be returned from the new method. + * + * @param {Array} variableDeclarations + * + * @return {Array} + */ + workOutReturnVariables(variableDeclarations) { + const startPoint = this.selectedBufferRange.end; + const scopeRange = this.parameterParser.getRangeForCurrentScope(this.editor, startPoint); + + const lookupRange = new Range(startPoint, scopeRange.end); + + const textAfterExtraction = this.editor.getTextInBufferRange(lookupRange); + const allVariablesAfterExtraction = textAfterExtraction.match(/\$[a-zA-Z0-9]+/g); + + if (allVariablesAfterExtraction === null) { return null; } + + variableDeclarations = variableDeclarations.filter(variable => { + if (Array.from(allVariablesAfterExtraction).includes(variable.name)) { return true; } + return false; + }); + + return variableDeclarations; + } + + /** + * Builds the return statement for the new method. + * + * @param {Array} variableDeclarations + * @param {String} arrayType ['word', 'brackets'] + * + * @return {String|null} + */ + buildReturnStatement(variableDeclarations, arrayType) { + if (arrayType == null) { arrayType = 'word'; } + if (variableDeclarations != null) { + if (variableDeclarations.length === 1) { + return `${this.tabText}return ${variableDeclarations[0].name};`; + + } else if (variableDeclarations.length > 1) { + let variables = variableDeclarations.reduce(function(previous, current) { + if (typeof previous !== 'string') { + previous = previous.name; + } + + return previous + ', ' + current.name; + }); + + if (arrayType === 'brackets') { + variables = `[${variables}]`; + + } else { + variables = `array(${variables})`; + } + + return `${this.tabText}return ${variables};`; + } + } + + return null; + } + + /** + * Checks if the new method will be returning any values. + * + * @return {Boolean} + */ + hasReturnValues() { + return (this.returnVariables !== null) && (this.returnVariables.length > 0); + } + + /** + * Returns if there are multiple return values. + * + * @return {Boolean} + */ + hasMultipleReturnValues() { + return (this.returnVariables !== null) && (this.returnVariables.length > 1); + } + }; + Builder.initClass(); + return Builder; +})()); diff --git a/lib/Refactoring/ExtractMethodProvider/ParameterParser.coffee b/lib/Refactoring/ExtractMethodProvider/ParameterParser.coffee deleted file mode 100644 index 2779ed6d..00000000 --- a/lib/Refactoring/ExtractMethodProvider/ParameterParser.coffee +++ /dev/null @@ -1,325 +0,0 @@ -{Point, Range} = require 'atom' - -module.exports = - -class ParameterParser - ###* - * Service object from the php-ide-serenata service - * - * @type {Service} - ### - service: null - - ###* - * @type {Object} - ### - typeHelper: null - - ###* - * List of all the variable declarations that have been process - * - * @type {Array} - ### - variableDeclarations: [] - - ###* - * The selected range that we are scanning for parameters in. - * - * @type {Range} - ### - selectedBufferRange: null - - ###* - * Constructor - * - * @param {Object} typeHelper - ### - constructor: (@typeHelper) -> - - ###* - * @param {Object} service - ### - setService: (@service) -> - - ###* - * Takes the editor and the range and loops through finding all the - * parameters that will be needed if this code was to be moved into - * its own function - * - * @param {TextEditor} editor - * @param {Range} selectedBufferRange - * - * @return {Promise} - ### - findParameters: (editor, selectedBufferRange) -> - @selectedBufferRange = selectedBufferRange - - parameters = [] - - editor.scanInBufferRange /\$[a-zA-Z0-9_]+/g, selectedBufferRange, (element) => - # Making sure we matched a variable and not a variable within a string - descriptions = editor.scopeDescriptorForBufferPosition(element.range.start) - indexOfDescriptor = descriptions.scopes.indexOf('variable.other.php') - if indexOfDescriptor > -1 - parameters.push { - name: element.matchText, - range: element.range - } - - regexFilters = [ - { - name: 'Foreach loops', - regex: /as\s(\$[a-zA-Z0-9_]+)(?:\s=>\s(\$[a-zA-Z0-9_]+))?/g - }, - { - name: 'For loops', - regex: /for\s*\(\s*(\$[a-zA-Z0-9_]+)\s*=/g - }, - { - name: 'Try catch', - regex: /catch(?:\(|\s)+.*?(\$[a-zA-Z0-9_]+)/g - }, - { - name: 'Closure' - regex: /function(?:\s)*?\((?:\$).*?\)/g - }, - { - name: 'Variable declarations', - regex: /(\$[a-zA-Z0-9]+)\s*?=(?!>|=)/g - } - ] - - getTypePromises = [] - variableDeclarations = [] - - for filter in regexFilters - editor.backwardsScanInBufferRange filter.regex, selectedBufferRange, (element) => - variables = element.matchText.match /\$[a-zA-Z0-9]+/g - startPoint = new Point(element.range.end.row, 0) - scopeRange = @getRangeForCurrentScope editor, startPoint - - if filter.name == 'Variable declarations' - chosenParameter = null - for parameter in parameters - if element.range.containsRange(parameter.range) - chosenParameter = parameter - break - - if chosenParameter != null - getTypePromises.push (@getTypesForParameter editor, chosenParameter) - variableDeclarations.push chosenParameter - - for variable in variables - parameters = parameters.filter (parameter) => - if parameter.name != variable - return true - if scopeRange.containsRange(parameter.range) - # If variable declaration is after parameter then it's - # still needed in parameters. - if element.range.start.row > parameter.range.start.row - return true - if element.range.start.row == parameter.range.start.row && - element.range.start.column > parameter.range.start.column - return true - - return false - - return true - - @variableDeclarations = @makeUnique variableDeclarations - - parameters = @makeUnique parameters - - # Grab the variable types of the parameters. - promises = [] - - parameters.forEach (parameter) => - # Removing $this from parameters as this doesn't need to be passed in. - return if parameter.name == '$this' - - promises.push @getTypesForParameter(editor, parameter) - - returnFirstResultHandler = (resultArray) -> - return resultArray[0] - - return Promise.all([Promise.all(promises), Promise.all(getTypePromises)]).then(returnFirstResultHandler) - - ###* - * Takes the current buffer position and returns a range of the current - * scope that the buffer position is in. - * - * For example this could be the code within an if statement or closure. - * - * @param {TextEditor} editor - * @param {Point} bufferPosition - * - * @return {Range} - ### - getRangeForCurrentScope: (editor, bufferPosition) -> - startScopePoint = null - endScopePoint = null - - # Tracks any extra scopes that might exist inside the scope we are - # looking for. - childScopes = 0 - - # First walk back until we find the start of the current scope. - for row in [bufferPosition.row .. 0] - line = editor.lineTextForBufferRow(row) - - continue if not line - - lastIndex = line.length - 1 - - for i in [lastIndex .. 0] - descriptions = editor.scopeDescriptorForBufferPosition( - [row, i] - ) - - indexOfDescriptor = descriptions.scopes.indexOf('punctuation.section.scope.end.php') - if indexOfDescriptor > -1 - childScopes++ - - indexOfDescriptor = descriptions.scopes.indexOf('punctuation.section.scope.begin.php') - if indexOfDescriptor > -1 - childScopes-- - - if childScopes == -1 - startScopePoint = new Point(row, 0) - break - - break if startScopePoint? - - if startScopePoint == null - startScopePoint = new Point(0, 0) - - childScopes = 0 - - # Walk forward until we find the end of the current scope - for row in [startScopePoint.row .. editor.getLineCount()] - line = editor.lineTextForBufferRow(row) - - continue if not line - - startIndex = 0 - - if startScopePoint.row == row - startIndex = line.length - 1 - - for i in [startIndex .. line.length - 1] - descriptions = editor.scopeDescriptorForBufferPosition( - [row, i] - ) - - indexOfDescriptor = descriptions.scopes.indexOf('punctuation.section.scope.begin.php') - if indexOfDescriptor > -1 - childScopes++ - - indexOfDescriptor = descriptions.scopes.indexOf('punctuation.section.scope.end.php') - if indexOfDescriptor > -1 - if childScopes > 0 - childScopes-- - - if childScopes == 0 - endScopePoint = new Point(row, i + 1) - break - - break if endScopePoint? - - return new Range(startScopePoint, endScopePoint) - - ###* - * Takes an array of parameters and removes any parameters that appear more - * that once with the same name. - * - * @param {Array} array - * - * @return {Array} - ### - makeUnique: (array) -> - return array.filter (filterItem, pos, self) -> - for i in [0 .. self.length - 1] - if self[i].name != filterItem.name - continue - - return pos == i - return true - ###* - * Generates the key used to store the parameters in the cache. - * - * @param {TextEditor} editor - * @param {Range} selectedBufferRange - * - * @return {String} - ### - buildKey: (editor, selectedBufferRange) -> - return editor.getPath() + JSON.stringify(selectedBufferRange) - - ###* - * Gets the type for the parameter given. - * - * @param {TextEditor} editor - * @param {Object} parameter - * - * @return {Promise} - ### - getTypesForParameter: (editor, parameter) -> - successHandler = (types) => - parameter.types = types - - typeResolutionPromises = [] - path = editor.getPath() - - localizeTypeSuccessHandler = (localizedType) => - return localizedType - - localizeTypeFailureHandler = () -> - return null - - for fqcn in parameter.types - if @typeHelper.isClassType(fqcn) - typeResolutionPromise = @service.localizeType( - path, - @selectedBufferRange.end.row + 1, - fqcn - ) - - typeResolutionPromises.push typeResolutionPromise.then( - localizeTypeSuccessHandler, - localizeTypeFailureHandler - ) - - else - typeResolutionPromises.push Promise.resolve(fqcn) - - combineResolvedTypesHandler = (processedTypeArray) -> - parameter.types = processedTypeArray - - return parameter - - return Promise.all(typeResolutionPromises).then( - combineResolvedTypesHandler, - combineResolvedTypesHandler - ) - - failureHandler = () => - return null - - return @service.deduceTypesAt(parameter.name, editor, @selectedBufferRange.end).then( - successHandler, - failureHandler - ) - - ###* - * Returns all the variable declarations that have been parsed. - * - * @return {Array} - ### - getVariableDeclarations: -> - return @variableDeclarations - - ###* - * Clean up any data from previous usage - ### - cleanUp: -> - @variableDeclarations = [] diff --git a/lib/Refactoring/ExtractMethodProvider/ParameterParser.js b/lib/Refactoring/ExtractMethodProvider/ParameterParser.js new file mode 100644 index 00000000..59b46633 --- /dev/null +++ b/lib/Refactoring/ExtractMethodProvider/ParameterParser.js @@ -0,0 +1,393 @@ +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS202: Simplify dynamic range loops + * DS206: Consider reworking classes to avoid initClass + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let ParameterParser; +const {Point, Range} = require('atom'); + +module.exports = + +(ParameterParser = (function() { + ParameterParser = class ParameterParser { + static initClass() { + /** + * Service object from the php-ide-serenata service + * + * @type {Service} + */ + this.prototype.service = null; + + /** + * @type {Object} + */ + this.prototype.typeHelper = null; + + /** + * List of all the variable declarations that have been process + * + * @type {Array} + */ + this.prototype.variableDeclarations = []; + + /** + * The selected range that we are scanning for parameters in. + * + * @type {Range} + */ + this.prototype.selectedBufferRange = null; + } + + /** + * Constructor + * + * @param {Object} typeHelper + */ + constructor(typeHelper) { + this.typeHelper = typeHelper; + } + + /** + * @param {Object} service + */ + setService(service) { + this.service = service; + } + + /** + * Takes the editor and the range and loops through finding all the + * parameters that will be needed if this code was to be moved into + * its own function + * + * @param {TextEditor} editor + * @param {Range} selectedBufferRange + * + * @return {Promise} + */ + findParameters(editor, selectedBufferRange) { + this.selectedBufferRange = selectedBufferRange; + + let parameters = []; + + editor.scanInBufferRange(/\$[a-zA-Z0-9_]+/g, selectedBufferRange, element => { + // Making sure we matched a variable and not a variable within a string + const descriptions = editor.scopeDescriptorForBufferPosition(element.range.start); + const indexOfDescriptor = descriptions.scopes.indexOf('variable.other.php'); + if (indexOfDescriptor > -1) { + return parameters.push({ + name: element.matchText, + range: element.range + }); + } + }); + + const regexFilters = [ + { + name: 'Foreach loops', + regex: /as\s(\$[a-zA-Z0-9_]+)(?:\s=>\s(\$[a-zA-Z0-9_]+))?/g + }, + { + name: 'For loops', + regex: /for\s*\(\s*(\$[a-zA-Z0-9_]+)\s*=/g + }, + { + name: 'Try catch', + regex: /catch(?:\(|\s)+.*?(\$[a-zA-Z0-9_]+)/g + }, + { + name: 'Closure', + regex: /function(?:\s)*?\((?:\$).*?\)/g + }, + { + name: 'Variable declarations', + regex: /(\$[a-zA-Z0-9]+)\s*?=(?!>|=)/g + } + ]; + + const getTypePromises = []; + const variableDeclarations = []; + + for (var filter of Array.from(regexFilters)) { + editor.backwardsScanInBufferRange(filter.regex, selectedBufferRange, element => { + const variables = element.matchText.match(/\$[a-zA-Z0-9]+/g); + const startPoint = new Point(element.range.end.row, 0); + const scopeRange = this.getRangeForCurrentScope(editor, startPoint); + + if (filter.name === 'Variable declarations') { + let chosenParameter = null; + for (let parameter of Array.from(parameters)) { + if (element.range.containsRange(parameter.range)) { + chosenParameter = parameter; + break; + } + } + + if (chosenParameter !== null) { + getTypePromises.push((this.getTypesForParameter(editor, chosenParameter))); + variableDeclarations.push(chosenParameter); + } + } + + return Array.from(variables).map((variable) => + (parameters = parameters.filter(parameter => { + if (parameter.name !== variable) { + return true; + } + if (scopeRange.containsRange(parameter.range)) { + // If variable declaration is after parameter then it's + // still needed in parameters. + if (element.range.start.row > parameter.range.start.row) { + return true; + } + if ((element.range.start.row === parameter.range.start.row) && + (element.range.start.column > parameter.range.start.column)) { + return true; + } + + return false; + } + + return true; + }))); + }); + } + + this.variableDeclarations = this.makeUnique(variableDeclarations); + + parameters = this.makeUnique(parameters); + + // Grab the variable types of the parameters. + const promises = []; + + parameters.forEach(parameter => { + // Removing $this from parameters as this doesn't need to be passed in. + if (parameter.name === '$this') { return; } + + return promises.push(this.getTypesForParameter(editor, parameter)); + }); + + const returnFirstResultHandler = resultArray => resultArray[0]; + + return Promise.all([Promise.all(promises), Promise.all(getTypePromises)]).then(returnFirstResultHandler); + } + + /** + * Takes the current buffer position and returns a range of the current + * scope that the buffer position is in. + * + * For example this could be the code within an if statement or closure. + * + * @param {TextEditor} editor + * @param {Point} bufferPosition + * + * @return {Range} + */ + getRangeForCurrentScope(editor, bufferPosition) { + let descriptions, i, indexOfDescriptor, line, row; + let asc; + let asc2, end; + let startScopePoint = null; + let endScopePoint = null; + + // Tracks any extra scopes that might exist inside the scope we are + // looking for. + let childScopes = 0; + + // First walk back until we find the start of the current scope. + for (({ row } = bufferPosition), asc = bufferPosition.row <= 0; asc ? row <= 0 : row >= 0; asc ? row++ : row--) { + var asc1; + line = editor.lineTextForBufferRow(row); + + if (!line) { continue; } + + const lastIndex = line.length - 1; + + for (i = lastIndex, asc1 = lastIndex <= 0; asc1 ? i <= 0 : i >= 0; asc1 ? i++ : i--) { + descriptions = editor.scopeDescriptorForBufferPosition( + [row, i] + ); + + indexOfDescriptor = descriptions.scopes.indexOf('punctuation.section.scope.end.php'); + if (indexOfDescriptor > -1) { + childScopes++; + } + + indexOfDescriptor = descriptions.scopes.indexOf('punctuation.section.scope.begin.php'); + if (indexOfDescriptor > -1) { + childScopes--; + + if (childScopes === -1) { + startScopePoint = new Point(row, 0); + break; + } + } + } + + if (startScopePoint != null) { break; } + } + + if (startScopePoint === null) { + startScopePoint = new Point(0, 0); + } + + childScopes = 0; + + // Walk forward until we find the end of the current scope + for (({ row } = startScopePoint), end = editor.getLineCount(), asc2 = startScopePoint.row <= end; asc2 ? row <= end : row >= end; asc2 ? row++ : row--) { + var asc3, end1; + line = editor.lineTextForBufferRow(row); + + if (!line) { continue; } + + let startIndex = 0; + + if (startScopePoint.row === row) { + startIndex = line.length - 1; + } + + for (i = startIndex, end1 = line.length - 1, asc3 = startIndex <= end1; asc3 ? i <= end1 : i >= end1; asc3 ? i++ : i--) { + descriptions = editor.scopeDescriptorForBufferPosition( + [row, i] + ); + + indexOfDescriptor = descriptions.scopes.indexOf('punctuation.section.scope.begin.php'); + if (indexOfDescriptor > -1) { + childScopes++; + } + + indexOfDescriptor = descriptions.scopes.indexOf('punctuation.section.scope.end.php'); + if (indexOfDescriptor > -1) { + if (childScopes > 0) { + childScopes--; + } + + if (childScopes === 0) { + endScopePoint = new Point(row, i + 1); + break; + } + } + } + + if (endScopePoint != null) { break; } + } + + return new Range(startScopePoint, endScopePoint); + } + + /** + * Takes an array of parameters and removes any parameters that appear more + * that once with the same name. + * + * @param {Array} array + * + * @return {Array} + */ + makeUnique(array) { + return array.filter(function(filterItem, pos, self) { + for (let i = 0, end = self.length - 1, asc = 0 <= end; asc ? i <= end : i >= end; asc ? i++ : i--) { + if (self[i].name !== filterItem.name) { + continue; + } + + return pos === i; + } + return true; + }); + } + /** + * Generates the key used to store the parameters in the cache. + * + * @param {TextEditor} editor + * @param {Range} selectedBufferRange + * + * @return {String} + */ + buildKey(editor, selectedBufferRange) { + return editor.getPath() + JSON.stringify(selectedBufferRange); + } + + /** + * Gets the type for the parameter given. + * + * @param {TextEditor} editor + * @param {Object} parameter + * + * @return {Promise} + */ + getTypesForParameter(editor, parameter) { + const successHandler = types => { + parameter.types = types; + + const typeResolutionPromises = []; + const path = editor.getPath(); + + const localizeTypeSuccessHandler = localizedType => { + return localizedType; + }; + + const localizeTypeFailureHandler = () => null; + + for (let fqcn of Array.from(parameter.types)) { + if (this.typeHelper.isClassType(fqcn)) { + const typeResolutionPromise = this.service.localizeType( + path, + this.selectedBufferRange.end.row + 1, + fqcn + ); + + typeResolutionPromises.push(typeResolutionPromise.then( + localizeTypeSuccessHandler, + localizeTypeFailureHandler + ) + ); + + } else { + typeResolutionPromises.push(Promise.resolve(fqcn)); + } + } + + const combineResolvedTypesHandler = function(processedTypeArray) { + parameter.types = processedTypeArray; + + return parameter; + }; + + return Promise.all(typeResolutionPromises).then( + combineResolvedTypesHandler, + combineResolvedTypesHandler + ); + }; + + const failureHandler = () => { + return null; + }; + + return this.service.deduceTypesAt(parameter.name, editor, this.selectedBufferRange.end).then( + successHandler, + failureHandler + ); + } + + /** + * Returns all the variable declarations that have been parsed. + * + * @return {Array} + */ + getVariableDeclarations() { + return this.variableDeclarations; + } + + /** + * Clean up any data from previous usage + */ + cleanUp() { + return this.variableDeclarations = []; + } + }; + ParameterParser.initClass(); + return ParameterParser; +})()); diff --git a/lib/Refactoring/ExtractMethodProvider/View.coffee b/lib/Refactoring/ExtractMethodProvider/View.coffee deleted file mode 100644 index ab2be871..00000000 --- a/lib/Refactoring/ExtractMethodProvider/View.coffee +++ /dev/null @@ -1,275 +0,0 @@ -{$, TextEditorView, View} = require 'atom-space-pen-views' - -Parser = require('./Builder') - -module.exports = - -class ExtractMethodView extends View - - ###* - * The callback to invoke when the user confirms his selections. - * - * @type {Callback} - ### - onDidConfirm : null - - ###* - * The callback to invoke when the user cancels the view. - * - * @type {Callback} - ### - onDidCancel : null - - ###* - * Settings of how to generate new method that will be passed to the parser - * - * @type {Object} - ### - settings : null - - ###* - * Builder to use when generating preview area - * - * @type {Builder} - ### - builder : null - - ###* - * Constructor. - * - * @param {Callback} onDidConfirm - * @param {Callback} onDidCancel - ### - constructor: (@onDidConfirm, @onDidCancel = null) -> - super() - - @settings = { - generateDocs: true, - methodName: '', - visibility: 'private', - tabs: false, - arraySyntax: 'brackets', - typeHinting: true, - generateDescPlaceholders: false - } - - ###* - * Content to be displayed when this view is shown. - ### - @content: -> - @div class: 'php-ide-serenata-refactoring-extract-method', => - @div outlet: 'methodNameForm', => - @subview 'methodNameEditor', new TextEditorView(mini:true, placeholderText: 'Enter a method name') - @div class: 'text-error error-message hide error-message--method-name', 'You must enter a method name!' - @div class: 'settings-view', => - @div class: 'section-body', => - @div class: 'control-group', => - @div class: 'controls', => - @label class: 'control-label', => - @div class: 'setting-title', 'Access Modifier' - @select outlet: 'accessMethodsInput', class: 'form-control', => - @option value: 'public', 'Public' - @option value: 'protected', 'Protected' - @option value: 'private', selected: "selected", 'Private' - @div class: 'control-group', => - @label class: 'control-label', => - @div class: 'setting-title', 'Documentation' - @div class: 'controls', => - @div class: 'checkbox', => - @label => - @input outlet: 'generateDocInput', type: 'checkbox', checked: true - @div class: 'setting-title', 'Generate documentation' - @div class: 'controls generate-docs-control', => - @div class: 'checkbox', => - @label => - @input outlet: 'generateDescPlaceholdersInput', type: 'checkbox' - @div class: 'setting-title', 'Generate description placeholders' - @div class: 'control-group', => - @label class: 'control-label', => - @div class: 'setting-title', 'Type hinting' - @div class: 'controls', => - @div class: 'checkbox', => - @label => - @input outlet: 'generateTypeHints', type: 'checkbox', checked: true - @div class: 'setting-title', 'Generate type hints' - @div class: 'return-multiple-control control-group', => - @label class: 'control-label', => - @div class: 'setting-title', 'Method styling' - @div class: 'controls', => - @div class: 'checkbox', => - @label => - @input outlet: 'arraySyntax', type: 'checkbox', checked: true - @div class: 'setting-title', 'Use PHP 5.4 array syntax (square brackets)' - @div class: 'control-group', => - @div class: 'controls', => - @label class: 'control-label', => - @div class: 'setting-title', 'Preview' - @div class: 'preview-area', => - @subview 'previewArea', new TextEditorView(), class: 'preview-area' - @div class: 'button-bar', => - @button class: 'btn btn-error inline-block-tight pull-left icon icon-circle-slash button--cancel', 'Cancel' - @button class: 'btn btn-success inline-block-tight pull-right icon icon-gear button--confirm', 'Extract' - @div class: 'clear-float' - - ###* - * @inheritdoc - ### - initialize: -> - atom.commands.add @element, - 'core:confirm': (event) => - @confirm() - event.stopPropagation() - 'core:cancel': (event) => - @cancel() - event.stopPropagation() - - @on 'click', 'button', (event) => - @confirm() if $(event.target).hasClass('button--confirm') - @cancel() if $(event.target).hasClass('button--cancel') - - @methodNameEditor.getModel().onDidChange () => - @settings.methodName = @methodNameEditor.getText() - $('.php-ide-serenata-refactoring-extract-method .error-message--method-name').addClass('hide'); - - @refreshPreviewArea() - - $(@accessMethodsInput[0]).change (event) => - @settings.visibility = $(event.target).val() - @refreshPreviewArea() - - $(@generateDocInput[0]).change (event) => - @settings.generateDocs = !@settings.generateDocs - if @settings.generateDocs == true - $('.php-ide-serenata-refactoring-extract-method .generate-docs-control').removeClass('hide') - else - $('.php-ide-serenata-refactoring-extract-method .generate-docs-control').addClass('hide') - - - @refreshPreviewArea() - - $(@generateDescPlaceholdersInput[0]).change (event) => - @settings.generateDescPlaceholders = !@settings.generateDescPlaceholders - @refreshPreviewArea() - - $(@generateTypeHints[0]).change (event) => - @settings.typeHinting = !@settings.typeHinting - @refreshPreviewArea() - - $(@arraySyntax[0]).change (event) => - if @settings.arraySyntax == 'word' - @settings.arraySyntax = 'brackets' - else - @settings.arraySyntax = 'word' - @refreshPreviewArea() - - @panel ?= atom.workspace.addModalPanel(item: this, visible: false) - - previewAreaTextEditor = @previewArea.getModel() - previewAreaTextEditor.setGrammar(atom.grammars.grammarForScopeName('text.html.php')) - - @on 'click', document, (event) => - event.stopPropagation() - - $(document).on 'click', (event) => - @cancel() if @panel?.isVisible() - - ###* - * Destroys the view and cleans up. - ### - destroy: -> - @panel.destroy() - @panel = null - - ###* - * Shows the view and refreshes the preview area with the current settings. - ### - present: -> - @panel.show() - @methodNameEditor.focus() - @methodNameEditor.setText('') - - ###* - * Hides the panel. - ### - hide: -> - @panel?.hide() - @restoreFocus() - - ###* - * Called when the user confirms the extraction and will then call - * onDidConfirm, if set. - ### - confirm: -> - if @settings.methodName == '' - $('.php-ide-serenata-refactoring-extract-method .error-message--method-name').removeClass('hide'); - return false - - if @onDidConfirm - @onDidConfirm(@getSettings()) - - @hide() - - ###* - * Called when the user cancels the extraction and will then call - * onDidCancel, if set. - ### - cancel: -> - if @onDidCancel - @onDidCancel() - - @hide() - - ###* - * Updates the preview area using the current setttings. - ### - refreshPreviewArea: -> - return unless @panel.isVisible() - - successHandler = (methodBody) => - if @builder.hasReturnValues() - if @builder.hasMultipleReturnValues() - $('.php-ide-serenata-refactoring-extract-method .return-multiple-control').removeClass('hide') - else - $('.php-ide-serenata-refactoring-extract-method .return-multiple-control').addClass('hide') - - $('.php-ide-serenata-refactoring-extract-method .return-control').removeClass('hide') - else - $('.php-ide-serenata-refactoring-extract-method .return-control').addClass('hide') - $('.php-ide-serenata-refactoring-extract-method .return-multiple-control').addClass('hide') - - @previewArea.getModel().getBuffer().setText(' - # Do nothing. - - @builder.buildMethod(@getSettings()).then(successHandler, failureHandler) - - ###* - * Stores the currently focused element so it can be returned focus after - * this panel is hidden. - ### - storeFocusedElement: -> - @previouslyFocusedElement = $(document.activeElement) - - ###* - * Restores focus back to the element that was focused before this panel - * was shown. - ### - restoreFocus: -> - @previouslyFocusedElement?.focus() - - ###* - * Sets the builder to use when generating the preview area. - * - * @param {Builder} builder - ### - setBuilder: (builder) -> - @builder = builder - - ###* - * Gets the settings currently set - * - * @return {Object} - ### - getSettings: -> - return @settings diff --git a/lib/Refactoring/ExtractMethodProvider/View.js b/lib/Refactoring/ExtractMethodProvider/View.js new file mode 100644 index 00000000..07e5fa90 --- /dev/null +++ b/lib/Refactoring/ExtractMethodProvider/View.js @@ -0,0 +1,360 @@ +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * DS206: Consider reworking classes to avoid initClass + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let ExtractMethodView; +const {$, TextEditorView, View} = require('atom-space-pen-views'); + +const Parser = require('./Builder'); + +module.exports = + +(ExtractMethodView = (function() { + ExtractMethodView = class ExtractMethodView extends View { + static initClass() { + + /** + * The callback to invoke when the user confirms his selections. + * + * @type {Callback} + */ + this.prototype.onDidConfirm = null; + + /** + * The callback to invoke when the user cancels the view. + * + * @type {Callback} + */ + this.prototype.onDidCancel = null; + + /** + * Settings of how to generate new method that will be passed to the parser + * + * @type {Object} + */ + this.prototype.settings = null; + + /** + * Builder to use when generating preview area + * + * @type {Builder} + */ + this.prototype.builder = null; + } + + /** + * Constructor. + * + * @param {Callback} onDidConfirm + * @param {Callback} onDidCancel + */ + constructor(onDidConfirm, onDidCancel = null) { + super(); + + this.onDidConfirm = onDidConfirm; + this.onDidCancel = onDidCancel; + + this.settings = { + generateDocs: true, + methodName: '', + visibility: 'private', + tabs: false, + arraySyntax: 'brackets', + typeHinting: true, + generateDescPlaceholders: false + }; + } + + /** + * Content to be displayed when this view is shown. + */ + static content() { + return this.div({class: 'php-ide-serenata-refactoring-extract-method'}, () => { + this.div({outlet: 'methodNameForm'}, () => { + this.subview('methodNameEditor', new TextEditorView({mini:true, placeholderText: 'Enter a method name'})); + this.div({class: 'text-error error-message hide error-message--method-name'}, 'You must enter a method name!'); + return this.div({class: 'settings-view'}, () => { + return this.div({class: 'section-body'}, () => { + this.div({class: 'control-group'}, () => { + return this.div({class: 'controls'}, () => { + return this.label({class: 'control-label'}, () => { + this.div({class: 'setting-title'}, 'Access Modifier'); + return this.select({outlet: 'accessMethodsInput', class: 'form-control'}, () => { + this.option({value: 'public'}, 'Public'); + this.option({value: 'protected'}, 'Protected'); + return this.option({value: 'private', selected: "selected"}, 'Private'); + }); + }); + }); + }); + this.div({class: 'control-group'}, () => { + return this.label({class: 'control-label'}, () => { + this.div({class: 'setting-title'}, 'Documentation'); + this.div({class: 'controls'}, () => { + return this.div({class: 'checkbox'}, () => { + return this.label(() => { + this.input({outlet: 'generateDocInput', type: 'checkbox', checked: true}); + return this.div({class: 'setting-title'}, 'Generate documentation'); + }); + }); + }); + return this.div({class: 'controls generate-docs-control'}, () => { + return this.div({class: 'checkbox'}, () => { + return this.label(() => { + this.input({outlet: 'generateDescPlaceholdersInput', type: 'checkbox'}); + return this.div({class: 'setting-title'}, 'Generate description placeholders'); + }); + }); + }); + }); + }); + this.div({class: 'control-group'}, () => { + return this.label({class: 'control-label'}, () => { + this.div({class: 'setting-title'}, 'Type hinting'); + return this.div({class: 'controls'}, () => { + return this.div({class: 'checkbox'}, () => { + return this.label(() => { + this.input({outlet: 'generateTypeHints', type: 'checkbox', checked: true}); + return this.div({class: 'setting-title'}, 'Generate type hints'); + }); + }); + }); + }); + }); + this.div({class: 'return-multiple-control control-group'}, () => { + return this.label({class: 'control-label'}, () => { + this.div({class: 'setting-title'}, 'Method styling'); + return this.div({class: 'controls'}, () => { + return this.div({class: 'checkbox'}, () => { + return this.label(() => { + this.input({outlet: 'arraySyntax', type: 'checkbox', checked: true}); + return this.div({class: 'setting-title'}, 'Use PHP 5.4 array syntax (square brackets)'); + }); + }); + }); + }); + }); + return this.div({class: 'control-group'}, () => { + return this.div({class: 'controls'}, () => { + return this.label({class: 'control-label'}, () => { + this.div({class: 'setting-title'}, 'Preview'); + return this.div({class: 'preview-area'}, () => { + return this.subview('previewArea', new TextEditorView(), {class: 'preview-area'}); + }); + }); + }); + }); + }); + }); + }); + return this.div({class: 'button-bar'}, () => { + this.button({class: 'btn btn-error inline-block-tight pull-left icon icon-circle-slash button--cancel'}, 'Cancel'); + this.button({class: 'btn btn-success inline-block-tight pull-right icon icon-gear button--confirm'}, 'Extract'); + return this.div({class: 'clear-float'}); + }); + }); + } + + /** + * @inheritdoc + */ + initialize() { + atom.commands.add(this.element, { + 'core:confirm': event => { + this.confirm(); + return event.stopPropagation(); + }, + 'core:cancel': event => { + this.cancel(); + return event.stopPropagation(); + } + } + ); + + this.on('click', 'button', event => { + if ($(event.target).hasClass('button--confirm')) { this.confirm(); } + if ($(event.target).hasClass('button--cancel')) { return this.cancel(); } + }); + + this.methodNameEditor.getModel().onDidChange(() => { + this.settings.methodName = this.methodNameEditor.getText(); + $('.php-ide-serenata-refactoring-extract-method .error-message--method-name').addClass('hide'); + + return this.refreshPreviewArea(); + }); + + $(this.accessMethodsInput[0]).change(event => { + this.settings.visibility = $(event.target).val(); + return this.refreshPreviewArea(); + }); + + $(this.generateDocInput[0]).change(event => { + this.settings.generateDocs = !this.settings.generateDocs; + if (this.settings.generateDocs === true) { + $('.php-ide-serenata-refactoring-extract-method .generate-docs-control').removeClass('hide'); + } else { + $('.php-ide-serenata-refactoring-extract-method .generate-docs-control').addClass('hide'); + } + + + return this.refreshPreviewArea(); + }); + + $(this.generateDescPlaceholdersInput[0]).change(event => { + this.settings.generateDescPlaceholders = !this.settings.generateDescPlaceholders; + return this.refreshPreviewArea(); + }); + + $(this.generateTypeHints[0]).change(event => { + this.settings.typeHinting = !this.settings.typeHinting; + return this.refreshPreviewArea(); + }); + + $(this.arraySyntax[0]).change(event => { + if (this.settings.arraySyntax === 'word') { + this.settings.arraySyntax = 'brackets'; + } else { + this.settings.arraySyntax = 'word'; + } + return this.refreshPreviewArea(); + }); + + if (this.panel == null) { this.panel = atom.workspace.addModalPanel({item: this, visible: false}); } + + const previewAreaTextEditor = this.previewArea.getModel(); + previewAreaTextEditor.setGrammar(atom.grammars.grammarForScopeName('text.html.php')); + + this.on('click', document, event => { + return event.stopPropagation(); + }); + + return $(document).on('click', event => { + if (this.panel != null ? this.panel.isVisible() : undefined) { return this.cancel(); } + }); + } + + /** + * Destroys the view and cleans up. + */ + destroy() { + this.panel.destroy(); + return this.panel = null; + } + + /** + * Shows the view and refreshes the preview area with the current settings. + */ + present() { + this.panel.show(); + this.methodNameEditor.focus(); + return this.methodNameEditor.setText(''); + } + + /** + * Hides the panel. + */ + hide() { + if (this.panel != null) { + this.panel.hide(); + } + return this.restoreFocus(); + } + + /** + * Called when the user confirms the extraction and will then call + * onDidConfirm, if set. + */ + confirm() { + if (this.settings.methodName === '') { + $('.php-ide-serenata-refactoring-extract-method .error-message--method-name').removeClass('hide'); + return false; + } + + if (this.onDidConfirm) { + this.onDidConfirm(this.getSettings()); + } + + return this.hide(); + } + + /** + * Called when the user cancels the extraction and will then call + * onDidCancel, if set. + */ + cancel() { + if (this.onDidCancel) { + this.onDidCancel(); + } + + return this.hide(); + } + + /** + * Updates the preview area using the current setttings. + */ + refreshPreviewArea() { + if (!this.panel.isVisible()) { return; } + + const successHandler = methodBody => { + if (this.builder.hasReturnValues()) { + if (this.builder.hasMultipleReturnValues()) { + $('.php-ide-serenata-refactoring-extract-method .return-multiple-control').removeClass('hide'); + } else { + $('.php-ide-serenata-refactoring-extract-method .return-multiple-control').addClass('hide'); + } + + $('.php-ide-serenata-refactoring-extract-method .return-control').removeClass('hide'); + } else { + $('.php-ide-serenata-refactoring-extract-method .return-control').addClass('hide'); + $('.php-ide-serenata-refactoring-extract-method .return-multiple-control').addClass('hide'); + } + + return this.previewArea.getModel().getBuffer().setText(` - - ###* - * @inheritdoc - ### - activate: (service) -> - super(service) - - atom.commands.add 'atom-workspace', "php-ide-serenata-refactoring:generate-getter": => - @executeCommand(true, false) - - atom.commands.add 'atom-workspace', "php-ide-serenata-refactoring:generate-setter": => - @executeCommand(false, true) - - atom.commands.add 'atom-workspace', "php-ide-serenata-refactoring:generate-getter-setter-pair": => - @executeCommand(true, true) - - ###* - * @inheritdoc - ### - deactivate: () -> - super() - - if @selectionView - @selectionView.destroy() - @selectionView = null - - ###* - * @inheritdoc - ### - getIntentionProviders: () -> - return [{ - grammarScopes: ['source.php'] - getIntentions: ({textEditor, bufferPosition}) => - successHandler = (currentClassName) => - return [] if not currentClassName - - return [ - { - priority : 100 - icon : 'gear' - title : 'Generate Getter And Setter Pair(s)' - - selected : () => - @executeCommand(true, true) - } - - { - priority : 100 - icon : 'gear' - title : 'Generate Getter(s)' - - selected : () => - @executeCommand(true, false) - }, - - { - priority : 100 - icon : 'gear' - title : 'Generate Setter(s)' - - selected : () => - @executeCommand(false, true) - } - ] - - failureHandler = () -> - return [] - - activeTextEditor = atom.workspace.getActiveTextEditor() - - return [] if not activeTextEditor - return [] if not @getCurrentProjectPhpVersion()? - - return @service.determineCurrentClassName(activeTextEditor, activeTextEditor.getCursorBufferPosition()).then(successHandler, failureHandler) - }] - - ###* - * Executes the generation. - * - * @param {boolean} enableGetterGeneration - * @param {boolean} enableSetterGeneration - ### - executeCommand: (enableGetterGeneration, enableSetterGeneration) -> - activeTextEditor = atom.workspace.getActiveTextEditor() - - return if not activeTextEditor - - @getSelectionView().setMetadata({editor: activeTextEditor}) - @getSelectionView().storeFocusedElement() - @getSelectionView().present() - - successHandler = (currentClassName) => - return if not currentClassName - - nestedSuccessHandler = (classInfo) => - enabledItems = [] - disabledItems = [] - - indentationLevel = activeTextEditor.indentationForBufferRow(activeTextEditor.getCursorBufferPosition().row) - - for name, property of classInfo.properties - getterName = 'get' + name.substr(0, 1).toUpperCase() + name.substr(1) - setterName = 'set' + name.substr(0, 1).toUpperCase() + name.substr(1) - - getterExists = if getterName of classInfo.methods then true else false - setterExists = if setterName of classInfo.methods then true else false - - data = { - name : name - types : property.types - needsGetter : enableGetterGeneration - needsSetter : enableSetterGeneration - getterName : getterName - setterName : setterName - tabText : activeTextEditor.getTabText() - indentationLevel : indentationLevel - maxLineLength : atom.config.get('editor.preferredLineLength', activeTextEditor.getLastCursor().getScopeDescriptor()) - } - - if (enableGetterGeneration and enableSetterGeneration and getterExists and setterExists) or - (enableGetterGeneration and getterExists) or - (enableSetterGeneration and setterExists) - data.className = 'php-ide-serenata-refactoring-strikethrough' - disabledItems.push(data) - - else - data.className = '' - enabledItems.push(data) - - @getSelectionView().setItems(enabledItems.concat(disabledItems)) - - nestedFailureHandler = () => - @getSelectionView().setItems([]) - - @service.getClassInfo(currentClassName).then(nestedSuccessHandler, nestedFailureHandler) - - failureHandler = () => - @getSelectionView().setItems([]) - - @service.determineCurrentClassName(activeTextEditor, activeTextEditor.getCursorBufferPosition()).then(successHandler, failureHandler) - - ###* - * Indicates if the specified type is a class type or not. - * - * @return {bool} - ### - isClassType: (type) -> - return if type.substr(0, 1).toUpperCase() == type.substr(0, 1) then true else false - - ###* - * Called when the selection of properties is cancelled. - * - * @param {Object|null} metadata - ### - onCancel: (metadata) -> - - ###* - * Called when the selection of properties is confirmed. - * - * @param {array} selectedItems - * @param {Object|null} metadata - ### - onConfirm: (selectedItems, metadata) -> - itemOutputs = [] - - for item in selectedItems - if item.needsGetter - itemOutputs.push(@generateGetterForItem(item)) - - if item.needsSetter - itemOutputs.push(@generateSetterForItem(item)) - - output = itemOutputs.join("\n").trim() - - metadata.editor.getBuffer().insert(metadata.editor.getCursorBufferPosition(), output) - - ###* - * Generates a getter for the specified selected item. - * - * @param {Object} item - * - * @return {string} - ### - generateGetterForItem: (item) -> - typeSpecification = @typeHelper.buildTypeSpecificationFromTypeArray(item.types) - - statements = [ - "return $this->#{item.name};" - ] - - functionText = @functionBuilder - .makePublic() - .setIsStatic(false) - .setIsAbstract(false) - .setName(item.getterName) - .setReturnType(@typeHelper.getReturnTypeHintForTypeSpecification(typeSpecification)) - .setParameters([]) - .setStatements(statements) - .setTabText(item.tabText) - .setIndentationLevel(item.indentationLevel) - .setMaxLineLength(item.maxLineLength) - .build() - - docblockText = @docblockBuilder.buildForMethod( - [], - typeSpecification, - false, - item.tabText.repeat(item.indentationLevel) - ) - - return docblockText + functionText - - ###* - * Generates a setter for the specified selected item. - * - * @param {Object} item - * - * @return {string} - ### - generateSetterForItem: (item) -> - typeSpecification = @typeHelper.buildTypeSpecificationFromTypeArray(item.types) - parameterTypeHint = @typeHelper.getTypeHintForTypeSpecification(typeSpecification) - - statements = [ - "$this->#{item.name} = $#{item.name};" - "return $this;" - ] - - parameters = [ - { - name : '$' + item.name - typeHint : parameterTypeHint.typeHint - defaultValue : if parameterTypeHint.shouldSetDefaultValueToNull then 'null' else null - } - ] - - functionText = @functionBuilder - .makePublic() - .setIsStatic(false) - .setIsAbstract(false) - .setName(item.setterName) - .setReturnType(null) - .setParameters(parameters) - .setStatements(statements) - .setTabText(item.tabText) - .setIndentationLevel(item.indentationLevel) - .setMaxLineLength(item.maxLineLength) - .build() - - docblockText = @docblockBuilder.buildForMethod( - [{name : '$' + item.name, type : typeSpecification}], - 'static', - false, - item.tabText.repeat(item.indentationLevel) - ) - - return docblockText + functionText - - ###* - * @return {Builder} - ### - getSelectionView: () -> - if not @selectionView? - View = require './GetterSetterProvider/View' - - @selectionView = new View(@onConfirm.bind(this), @onCancel.bind(this)) - @selectionView.setLoading('Loading class information...') - @selectionView.setEmptyMessage('No properties found.') - - return @selectionView diff --git a/lib/Refactoring/GetterSetterProvider.js b/lib/Refactoring/GetterSetterProvider.js new file mode 100644 index 00000000..1682b654 --- /dev/null +++ b/lib/Refactoring/GetterSetterProvider.js @@ -0,0 +1,360 @@ +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS206: Consider reworking classes to avoid initClass + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let GetterSetterProvider; +const AbstractProvider = require('./AbstractProvider'); + +module.exports = + +//#* +// Provides getter and setter (accessor and mutator) generation capabilities. +//# +(GetterSetterProvider = (function() { + GetterSetterProvider = class GetterSetterProvider extends AbstractProvider { + static initClass() { + /** + * The view that allows the user to select the properties to generate for. + */ + this.prototype.selectionView = null; + + /** + * Aids in building methods. + */ + this.prototype.functionBuilder = null; + + /** + * The docblock builder. + */ + this.prototype.docblockBuilder = null; + + /** + * The type helper. + */ + this.prototype.typeHelper = null; + } + + /** + * @param {Object} typeHelper + * @param {Object} functionBuilder + * @param {Object} docblockBuilder + */ + constructor(typeHelper, functionBuilder, docblockBuilder) { + super(); + + this.typeHelper = typeHelper; + this.functionBuilder = functionBuilder; + this.docblockBuilder = docblockBuilder; + } + + /** + * @inheritdoc + */ + activate(service) { + super.activate(service); + + atom.commands.add('atom-workspace', { "php-ide-serenata-refactoring:generate-getter": () => { + return this.executeCommand(true, false); + } + } + ); + + atom.commands.add('atom-workspace', { "php-ide-serenata-refactoring:generate-setter": () => { + return this.executeCommand(false, true); + } + } + ); + + return atom.commands.add('atom-workspace', { "php-ide-serenata-refactoring:generate-getter-setter-pair": () => { + return this.executeCommand(true, true); + } + } + ); + } + + /** + * @inheritdoc + */ + deactivate() { + super.deactivate(); + + if (this.selectionView) { + this.selectionView.destroy(); + return this.selectionView = null; + } + } + + /** + * @inheritdoc + */ + getIntentionProviders() { + return [{ + grammarScopes: ['source.php'], + getIntentions: ({textEditor, bufferPosition}) => { + const successHandler = currentClassName => { + if (!currentClassName) { return []; } + + return [ + { + priority : 100, + icon : 'gear', + title : 'Generate Getter And Setter Pair(s)', + + selected : () => { + return this.executeCommand(true, true); + } + }, + + { + priority : 100, + icon : 'gear', + title : 'Generate Getter(s)', + + selected : () => { + return this.executeCommand(true, false); + } + }, + + { + priority : 100, + icon : 'gear', + title : 'Generate Setter(s)', + + selected : () => { + return this.executeCommand(false, true); + } + } + ]; + }; + + const failureHandler = () => []; + + const activeTextEditor = atom.workspace.getActiveTextEditor(); + + if (!activeTextEditor) { return []; } + if ((this.getCurrentProjectPhpVersion() == null)) { return []; } + + return this.service.determineCurrentClassName(activeTextEditor, activeTextEditor.getCursorBufferPosition()).then(successHandler, failureHandler); + } + }]; + } + + /** + * Executes the generation. + * + * @param {boolean} enableGetterGeneration + * @param {boolean} enableSetterGeneration + */ + executeCommand(enableGetterGeneration, enableSetterGeneration) { + const activeTextEditor = atom.workspace.getActiveTextEditor(); + + if (!activeTextEditor) { return; } + + this.getSelectionView().setMetadata({editor: activeTextEditor}); + this.getSelectionView().storeFocusedElement(); + this.getSelectionView().present(); + + const successHandler = currentClassName => { + if (!currentClassName) { return; } + + const nestedSuccessHandler = classInfo => { + const enabledItems = []; + const disabledItems = []; + + const indentationLevel = activeTextEditor.indentationForBufferRow(activeTextEditor.getCursorBufferPosition().row); + + for (let name in classInfo.properties) { + const property = classInfo.properties[name]; + const getterName = `get${name.substr(0, 1).toUpperCase()}${name.substr(1)}`; + const setterName = `set${name.substr(0, 1).toUpperCase()}${name.substr(1)}`; + + const getterExists = getterName in classInfo.methods ? true : false; + const setterExists = setterName in classInfo.methods ? true : false; + + const data = { + name, + types : property.types, + needsGetter : enableGetterGeneration, + needsSetter : enableSetterGeneration, + getterName, + setterName, + tabText : activeTextEditor.getTabText(), + indentationLevel, + maxLineLength : atom.config.get('editor.preferredLineLength', activeTextEditor.getLastCursor().getScopeDescriptor()) + }; + + if ((enableGetterGeneration && enableSetterGeneration && getterExists && setterExists) || + (enableGetterGeneration && getterExists) || + (enableSetterGeneration && setterExists)) { + data.className = 'php-ide-serenata-refactoring-strikethrough'; + disabledItems.push(data); + + } else { + data.className = ''; + enabledItems.push(data); + } + } + + return this.getSelectionView().setItems(enabledItems.concat(disabledItems)); + }; + + const nestedFailureHandler = () => { + return this.getSelectionView().setItems([]); + }; + + return this.service.getClassInfo(currentClassName).then(nestedSuccessHandler, nestedFailureHandler); + }; + + const failureHandler = () => { + return this.getSelectionView().setItems([]); + }; + + return this.service.determineCurrentClassName(activeTextEditor, activeTextEditor.getCursorBufferPosition()).then(successHandler, failureHandler); + } + + /** + * Indicates if the specified type is a class type or not. + * + * @return {bool} + */ + isClassType(type) { + if (type.substr(0, 1).toUpperCase() === type.substr(0, 1)) { return true; } else { return false; } + } + + /** + * Called when the selection of properties is cancelled. + * + * @param {Object|null} metadata + */ + onCancel(metadata) {} + + /** + * Called when the selection of properties is confirmed. + * + * @param {array} selectedItems + * @param {Object|null} metadata + */ + onConfirm(selectedItems, metadata) { + const itemOutputs = []; + + for (let item of Array.from(selectedItems)) { + if (item.needsGetter) { + itemOutputs.push(this.generateGetterForItem(item)); + } + + if (item.needsSetter) { + itemOutputs.push(this.generateSetterForItem(item)); + } + } + + const output = itemOutputs.join("\n").trim(); + + return metadata.editor.getBuffer().insert(metadata.editor.getCursorBufferPosition(), output); + } + + /** + * Generates a getter for the specified selected item. + * + * @param {Object} item + * + * @return {string} + */ + generateGetterForItem(item) { + const typeSpecification = this.typeHelper.buildTypeSpecificationFromTypeArray(item.types); + + const statements = [ + `return $this->${item.name};` + ]; + + const functionText = this.functionBuilder + .makePublic() + .setIsStatic(false) + .setIsAbstract(false) + .setName(item.getterName) + .setReturnType(this.typeHelper.getReturnTypeHintForTypeSpecification(typeSpecification)) + .setParameters([]) + .setStatements(statements) + .setTabText(item.tabText) + .setIndentationLevel(item.indentationLevel) + .setMaxLineLength(item.maxLineLength) + .build(); + + const docblockText = this.docblockBuilder.buildForMethod( + [], + typeSpecification, + false, + item.tabText.repeat(item.indentationLevel) + ); + + return docblockText + functionText; + } + + /** + * Generates a setter for the specified selected item. + * + * @param {Object} item + * + * @return {string} + */ + generateSetterForItem(item) { + const typeSpecification = this.typeHelper.buildTypeSpecificationFromTypeArray(item.types); + const parameterTypeHint = this.typeHelper.getTypeHintForTypeSpecification(typeSpecification); + + const statements = [ + `$this->${item.name} = $${item.name};`, + "return $this;" + ]; + + const parameters = [ + { + name : `$${item.name}`, + typeHint : parameterTypeHint.typeHint, + defaultValue : parameterTypeHint.shouldSetDefaultValueToNull ? 'null' : null + } + ]; + + const functionText = this.functionBuilder + .makePublic() + .setIsStatic(false) + .setIsAbstract(false) + .setName(item.setterName) + .setReturnType(null) + .setParameters(parameters) + .setStatements(statements) + .setTabText(item.tabText) + .setIndentationLevel(item.indentationLevel) + .setMaxLineLength(item.maxLineLength) + .build(); + + const docblockText = this.docblockBuilder.buildForMethod( + [{name : `$${item.name}`, type : typeSpecification}], + 'static', + false, + item.tabText.repeat(item.indentationLevel) + ); + + return docblockText + functionText; + } + + /** + * @return {Builder} + */ + getSelectionView() { + if ((this.selectionView == null)) { + const View = require('./GetterSetterProvider/View'); + + this.selectionView = new View(this.onConfirm.bind(this), this.onCancel.bind(this)); + this.selectionView.setLoading('Loading class information...'); + this.selectionView.setEmptyMessage('No properties found.'); + } + + return this.selectionView; + } + }; + GetterSetterProvider.initClass(); + return GetterSetterProvider; +})()); diff --git a/lib/Refactoring/GetterSetterProvider/View.coffee b/lib/Refactoring/GetterSetterProvider/View.coffee deleted file mode 100644 index e44f4d06..00000000 --- a/lib/Refactoring/GetterSetterProvider/View.coffee +++ /dev/null @@ -1,35 +0,0 @@ -{$, $$, SelectListView} = require 'atom-space-pen-views' - -MultiSelectionView = require '../Utility/MultiSelectionView.coffee' - -module.exports = - -##* -# An extension on SelectListView from atom-space-pen-views that allows multiple selections. -## -class View extends MultiSelectionView - ###* - * @inheritdoc - ### - createWidgets: () -> - checkboxBar = $$ -> - @div class: 'checkbox-bar settings-view', => - @div class: 'controls', => - @div class: 'block text-line', => - @label class: 'icon icon-info', 'Tip: The order in which items are selected determines the order of the output.' - - checkboxBar.appendTo(this) - - # Ensure that button clicks are actually handled. - @on 'mousedown', ({target}) => - return false if $(target).hasClass('checkbox-input') - return false if $(target).hasClass('checkbox-label-text') - - super() - - ###* - * @inheritdoc - ### - invokeOnDidConfirm: () -> - if @onDidConfirm - @onDidConfirm(@selectedItems, @getMetadata()) diff --git a/lib/Refactoring/GetterSetterProvider/View.js b/lib/Refactoring/GetterSetterProvider/View.js new file mode 100644 index 00000000..3da5d91a --- /dev/null +++ b/lib/Refactoring/GetterSetterProvider/View.js @@ -0,0 +1,50 @@ +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let View; +const {$, $$, SelectListView} = require('atom-space-pen-views'); + +const MultiSelectionView = require('../Utility/MultiSelectionView'); + +module.exports = + +//#* +// An extension on SelectListView from atom-space-pen-views that allows multiple selections. +//# +(View = class View extends MultiSelectionView { + /** + * @inheritdoc + */ + createWidgets() { + const checkboxBar = $$(function() { + return this.div({class: 'checkbox-bar settings-view'}, () => { + return this.div({class: 'controls'}, () => { + return this.div({class: 'block text-line'}, () => { + return this.label({class: 'icon icon-info'}, 'Tip: The order in which items are selected determines the order of the output.'); + }); + }); + }); + }); + + checkboxBar.appendTo(this); + + // Ensure that button clicks are actually handled. + this.on('mousedown', ({target}) => { + if ($(target).hasClass('checkbox-input')) { return false; } + if ($(target).hasClass('checkbox-label-text')) { return false; } + }); + + return super.createWidgets(); + } + + /** + * @inheritdoc + */ + invokeOnDidConfirm() { + if (this.onDidConfirm) { + return this.onDidConfirm(this.selectedItems, this.getMetadata()); + } + } +}); diff --git a/lib/Refactoring/IntroducePropertyProvider.coffee b/lib/Refactoring/IntroducePropertyProvider.coffee deleted file mode 100644 index 3f78976a..00000000 --- a/lib/Refactoring/IntroducePropertyProvider.coffee +++ /dev/null @@ -1,127 +0,0 @@ -{Point} = require 'atom' - -AbstractProvider = require './AbstractProvider' - -module.exports = - -##* -# Provides property generation for non-existent properties. -## -class IntroducePropertyProvider extends AbstractProvider - ###* - * The docblock builder. - ### - docblockBuilder: null - - ###* - * @param {Object} docblockBuilder - ### - constructor: (@docblockBuilder) -> - - ###* - * @inheritdoc - ### - getIntentionProviders: () -> - return [{ - grammarScopes: ['variable.other.property.php'] - getIntentions: ({textEditor, bufferPosition}) => - nameRange = textEditor.bufferRangeForScopeAtCursor('variable.other.property') - - return if not nameRange? - return [] if not @getCurrentProjectPhpVersion()? - - name = textEditor.getTextInBufferRange(nameRange) - - return @getIntentions(textEditor, bufferPosition, name) - }] - - ###* - * @param {TextEditor} editor - * @param {Point} triggerPosition - * @param {String} name - ### - getIntentions: (editor, triggerPosition, name) -> - failureHandler = () => - return [] - - successHandler = (currentClassName) => - return [] if not currentClassName? - - nestedSuccessHandler = (classInfo) => - intentions = [] - - return intentions if not classInfo - - if name not of classInfo.properties - intentions.push({ - priority : 100 - icon : 'gear' - title : 'Introduce New Property' - - selected : () => - @introducePropertyFor(editor, classInfo, name) - }) - - return intentions - - @service.getClassInfo(currentClassName).then(nestedSuccessHandler, failureHandler) - - @service.determineCurrentClassName(editor, triggerPosition).then(successHandler, failureHandler) - - ###* - * @param {TextEditor} editor - * @param {Object} classData - * @param {String} name - ### - introducePropertyFor: (editor, classData, name) -> - indentationLevel = editor.indentationForBufferRow(classData.startLine - 1) + 1 - - tabText = editor.getTabText().repeat(indentationLevel) - - docblock = @docblockBuilder.buildForProperty( - 'mixed', - false, - tabText - ) - - property = "#{tabText}protected $#{name};\n\n" - - point = @findLocationToInsertProperty(editor, classData) - - editor.getBuffer().insert(point, docblock + property) - - - ###* - * @param {TextEditor} editor - * @param {Object} classData - * - * @return {Point} - ### - findLocationToInsertProperty: (editor, classData) -> - startLine = null - - # Try to place the new property underneath the existing properties. - for name,propertyData of classData.properties - if propertyData.declaringStructure.name == classData.name - startLine = propertyData.endLine + 1 - - if not startLine? - # Ensure we don't end up somewhere in the middle of the class definition if it spans multiple lines. - lineCount = editor.getLineCount() - - for line in [classData.startLine .. lineCount] - lineText = editor.lineTextForBufferRow(line) - - continue if not lineText? - - for i in [0 .. lineText.length - 1] - if lineText[i] == '{' - startLine = line + 1 - break - - break if startLine? - - if not startLine? - startLine = classData.startLine + 1 - - return new Point(startLine, -1) diff --git a/lib/Refactoring/IntroducePropertyProvider.js b/lib/Refactoring/IntroducePropertyProvider.js new file mode 100644 index 00000000..4d43bf7e --- /dev/null +++ b/lib/Refactoring/IntroducePropertyProvider.js @@ -0,0 +1,165 @@ +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * DS202: Simplify dynamic range loops + * DS206: Consider reworking classes to avoid initClass + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let IntroducePropertyProvider; +const {Point} = require('atom'); + +const AbstractProvider = require('./AbstractProvider'); + +module.exports = + +//#* +// Provides property generation for non-existent properties. +//# +(IntroducePropertyProvider = (function() { + IntroducePropertyProvider = class IntroducePropertyProvider extends AbstractProvider { + static initClass() { + /** + * The docblock builder. + */ + this.prototype.docblockBuilder = null; + } + + /** + * @param {Object} docblockBuilder + */ + constructor(docblockBuilder) { + super(); + + this.docblockBuilder = docblockBuilder; + } + + /** + * @inheritdoc + */ + getIntentionProviders() { + return [{ + grammarScopes: ['variable.other.property.php'], + getIntentions: ({textEditor, bufferPosition}) => { + const nameRange = textEditor.bufferRangeForScopeAtCursor('variable.other.property'); + + if ((nameRange == null)) { return; } + if ((this.getCurrentProjectPhpVersion() == null)) { return []; } + + const name = textEditor.getTextInBufferRange(nameRange); + + return this.getIntentions(textEditor, bufferPosition, name); + } + }]; + } + + /** + * @param {TextEditor} editor + * @param {Point} triggerPosition + * @param {String} name + */ + getIntentions(editor, triggerPosition, name) { + const failureHandler = () => { + return []; + }; + + const successHandler = currentClassName => { + if ((currentClassName == null)) { return []; } + + const nestedSuccessHandler = classInfo => { + const intentions = []; + + if (!classInfo) { return intentions; } + + if (!(name in classInfo.properties)) { + intentions.push({ + priority : 100, + icon : 'gear', + title : 'Introduce New Property', + + selected : () => { + return this.introducePropertyFor(editor, classInfo, name); + } + }); + } + + return intentions; + }; + + return this.service.getClassInfo(currentClassName).then(nestedSuccessHandler, failureHandler); + }; + + return this.service.determineCurrentClassName(editor, triggerPosition).then(successHandler, failureHandler); + } + + /** + * @param {TextEditor} editor + * @param {Object} classData + * @param {String} name + */ + introducePropertyFor(editor, classData, name) { + const indentationLevel = editor.indentationForBufferRow(classData.startLine - 1) + 1; + + const tabText = editor.getTabText().repeat(indentationLevel); + + const docblock = this.docblockBuilder.buildForProperty( + 'mixed', + false, + tabText + ); + + const property = `${tabText}protected $${name};\n\n`; + + const point = this.findLocationToInsertProperty(editor, classData); + + return editor.getBuffer().insert(point, docblock + property); + } + + + /** + * @param {TextEditor} editor + * @param {Object} classData + * + * @return {Point} + */ + findLocationToInsertProperty(editor, classData) { + let startLine = null; + + // Try to place the new property underneath the existing properties. + for (let name in classData.properties) { + const propertyData = classData.properties[name]; + if (propertyData.declaringStructure.name === classData.name) { + startLine = propertyData.endLine + 1; + } + } + + if ((startLine == null)) { + // Ensure we don't end up somewhere in the middle of the class definition if it spans multiple lines. + const lineCount = editor.getLineCount(); + + for (let line = classData.startLine, end = lineCount, asc = classData.startLine <= end; asc ? line <= end : line >= end; asc ? line++ : line--) { + const lineText = editor.lineTextForBufferRow(line); + + if ((lineText == null)) { continue; } + + for (let i = 0, end1 = lineText.length - 1, asc1 = 0 <= end1; asc1 ? i <= end1 : i >= end1; asc1 ? i++ : i--) { + if (lineText[i] === '{') { + startLine = line + 1; + break; + } + } + + if (startLine != null) { break; } + } + } + + if ((startLine == null)) { + startLine = classData.startLine + 1; + } + + return new Point(startLine, -1); + } + }; + IntroducePropertyProvider.initClass(); + return IntroducePropertyProvider; +})()); diff --git a/lib/Refactoring/OverrideMethodProvider.coffee b/lib/Refactoring/OverrideMethodProvider.coffee deleted file mode 100644 index 8ab27f3c..00000000 --- a/lib/Refactoring/OverrideMethodProvider.coffee +++ /dev/null @@ -1,209 +0,0 @@ -AbstractProvider = require './AbstractProvider' - -module.exports = - -##* -# Provides the ability to implement interface methods. -## -class OverrideMethodProvider extends AbstractProvider - ###* - * The view that allows the user to select the properties to generate for. - ### - selectionView: null - - ###* - * @type {Object} - ### - docblockBuilder: null - - ###* - * @type {Object} - ### - functionBuilder: null - - ###* - * @param {Object} docblockBuilder - * @param {Object} functionBuilder - ### - constructor: (@docblockBuilder, @functionBuilder) -> - - ###* - * @inheritdoc - ### - deactivate: () -> - super() - - if @selectionView - @selectionView.destroy() - @selectionView = null - - ###* - * @inheritdoc - ### - getIntentionProviders: () -> - return [{ - grammarScopes: ['source.php'] - getIntentions: ({textEditor, bufferPosition}) => - return [] if not @getCurrentProjectPhpVersion()? - - return @getStubInterfaceMethodIntentions(textEditor, bufferPosition) - }] - - ###* - * @param {TextEditor} editor - * @param {Point} triggerPosition - ### - getStubInterfaceMethodIntentions: (editor, triggerPosition) -> - failureHandler = () -> - return [] - - successHandler = (currentClassName) => - return [] if not currentClassName - - nestedSuccessHandler = (classInfo) => - return [] if not classInfo - - items = [] - - for name, method of classInfo.methods - data = { - name : name - method : method - } - - # Interface methods can already be stubbed via StubInterfaceMethodProvider. - continue if method.declaringStructure.type == 'interface' - - # Abstract methods can already be stubbed via StubAbstractMethodProvider. - continue if method.isAbstract - - if method.declaringStructure.name != classInfo.name - items.push(data) - - return [] if items.length == 0 - - @getSelectionView().setItems(items) - - return [ - { - priority : 100 - icon : 'link' - title : 'Override Method(s)' - - selected : () => - @executeStubInterfaceMethods(editor) - } - ] - - return @service.getClassInfo(currentClassName).then(nestedSuccessHandler, failureHandler) - - return @service.determineCurrentClassName(editor, triggerPosition).then(successHandler, failureHandler) - - ###* - * @param {TextEditor} editor - * @param {Point} triggerPosition - ### - executeStubInterfaceMethods: (editor) -> - @getSelectionView().setMetadata({editor: editor}) - @getSelectionView().storeFocusedElement() - @getSelectionView().present() - - ###* - * Called when the selection of properties is cancelled. - ### - onCancel: (metadata) -> - - ###* - * Called when the selection of properties is confirmed. - * - * @param {array} selectedItems - * @param {Object|null} metadata - ### - onConfirm: (selectedItems, metadata) -> - itemOutputs = [] - - tabText = metadata.editor.getTabText() - indentationLevel = metadata.editor.indentationForBufferRow(metadata.editor.getCursorBufferPosition().row) - maxLineLength = atom.config.get('editor.preferredLineLength', metadata.editor.getLastCursor().getScopeDescriptor()) - - for item in selectedItems - stub = @generateStubForInterfaceMethod(item.method, tabText, indentationLevel, maxLineLength) - - itemOutputs.push(stub) - - output = itemOutputs.join("\n").trim() - - metadata.editor.insertText(output) - - ###* - * Generates an override for the specified selected data. - * - * @param {Object} data - * @param {String} tabText - * @param {Number} indentationLevel - * @param {Number} maxLineLength - * - * @return {string} - ### - generateStubForInterfaceMethod: (data, tabText, indentationLevel, maxLineLength) -> - parameterNames = data.parameters.map (item) -> - return '$' + item.name - - hasReturnValue = @hasReturnValue(data) - - parentCallStatement = '' - - if hasReturnValue - parentCallStatement += '$value = ' - - parentCallStatement += 'parent::' + data.name + '(' - parentCallStatement += parameterNames.join(', ') - parentCallStatement += ');' - - statements = [ - parentCallStatement - '' - '// TODO' - ] - - if hasReturnValue - statements.push('') - statements.push('return $value;') - - functionText = @functionBuilder - .setFromRawMethodData(data) - .setIsAbstract(false) - .setStatements(statements) - .setTabText(tabText) - .setIndentationLevel(indentationLevel) - .setMaxLineLength(maxLineLength) - .build() - - docblockText = @docblockBuilder.buildByLines(['@inheritDoc'], tabText.repeat(indentationLevel)) - - return docblockText + functionText - - ###* - * @param {Object} data - * - * @return {Boolean} - ### - hasReturnValue: (data) -> - return false if data.name == '__construct' - return false if data.returnTypes.length == 0 - return false if data.returnTypes.length == 1 and data.returnTypes[0].type == 'void' - - return true - - ###* - * @return {Builder} - ### - getSelectionView: () -> - if not @selectionView? - View = require './OverrideMethodProvider/View' - - @selectionView = new View(@onConfirm.bind(this), @onCancel.bind(this)) - @selectionView.setLoading('Loading class information...') - @selectionView.setEmptyMessage('No overridable methods found.') - - return @selectionView diff --git a/lib/Refactoring/OverrideMethodProvider.js b/lib/Refactoring/OverrideMethodProvider.js new file mode 100644 index 00000000..9e98bcce --- /dev/null +++ b/lib/Refactoring/OverrideMethodProvider.js @@ -0,0 +1,248 @@ +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS206: Consider reworking classes to avoid initClass + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let OverrideMethodProvider; +const AbstractProvider = require('./AbstractProvider'); + +module.exports = + +//#* +// Provides the ability to implement interface methods. +//# +(OverrideMethodProvider = (function() { + OverrideMethodProvider = class OverrideMethodProvider extends AbstractProvider { + static initClass() { + /** + * The view that allows the user to select the properties to generate for. + */ + this.prototype.selectionView = null; + + /** + * @type {Object} + */ + this.prototype.docblockBuilder = null; + + /** + * @type {Object} + */ + this.prototype.functionBuilder = null; + } + + /** + * @param {Object} docblockBuilder + * @param {Object} functionBuilder + */ + constructor(docblockBuilder, functionBuilder) { + super(); + + this.docblockBuilder = docblockBuilder; + this.functionBuilder = functionBuilder; + } + + /** + * @inheritdoc + */ + deactivate() { + super.deactivate(); + + if (this.selectionView) { + this.selectionView.destroy(); + return this.selectionView = null; + } + } + + /** + * @inheritdoc + */ + getIntentionProviders() { + return [{ + grammarScopes: ['source.php'], + getIntentions: ({textEditor, bufferPosition}) => { + if ((this.getCurrentProjectPhpVersion() == null)) { return []; } + + return this.getStubInterfaceMethodIntentions(textEditor, bufferPosition); + } + }]; + } + + /** + * @param {TextEditor} editor + * @param {Point} triggerPosition + */ + getStubInterfaceMethodIntentions(editor, triggerPosition) { + const failureHandler = () => []; + + const successHandler = currentClassName => { + if (!currentClassName) { return []; } + + const nestedSuccessHandler = classInfo => { + if (!classInfo) { return []; } + + const items = []; + + for (let name in classInfo.methods) { + const method = classInfo.methods[name]; + const data = { + name, + method + }; + + // Interface methods can already be stubbed via StubInterfaceMethodProvider. + if (method.declaringStructure.type === 'interface') { continue; } + + // Abstract methods can already be stubbed via StubAbstractMethodProvider. + if (method.isAbstract) { continue; } + + if (method.declaringStructure.name !== classInfo.name) { + items.push(data); + } + } + + if (items.length === 0) { return []; } + + this.getSelectionView().setItems(items); + + return [ + { + priority : 100, + icon : 'link', + title : 'Override Method(s)', + + selected : () => { + return this.executeStubInterfaceMethods(editor); + } + } + ]; + }; + + return this.service.getClassInfo(currentClassName).then(nestedSuccessHandler, failureHandler); + }; + + return this.service.determineCurrentClassName(editor, triggerPosition).then(successHandler, failureHandler); + } + + /** + * @param {TextEditor} editor + * @param {Point} triggerPosition + */ + executeStubInterfaceMethods(editor) { + this.getSelectionView().setMetadata({editor}); + this.getSelectionView().storeFocusedElement(); + return this.getSelectionView().present(); + } + + /** + * Called when the selection of properties is cancelled. + */ + onCancel(metadata) {} + + /** + * Called when the selection of properties is confirmed. + * + * @param {array} selectedItems + * @param {Object|null} metadata + */ + onConfirm(selectedItems, metadata) { + const itemOutputs = []; + + const tabText = metadata.editor.getTabText(); + const indentationLevel = metadata.editor.indentationForBufferRow(metadata.editor.getCursorBufferPosition().row); + const maxLineLength = atom.config.get('editor.preferredLineLength', metadata.editor.getLastCursor().getScopeDescriptor()); + + for (let item of Array.from(selectedItems)) { + const stub = this.generateStubForInterfaceMethod(item.method, tabText, indentationLevel, maxLineLength); + + itemOutputs.push(stub); + } + + const output = itemOutputs.join("\n").trim(); + + return metadata.editor.insertText(output); + } + + /** + * Generates an override for the specified selected data. + * + * @param {Object} data + * @param {String} tabText + * @param {Number} indentationLevel + * @param {Number} maxLineLength + * + * @return {string} + */ + generateStubForInterfaceMethod(data, tabText, indentationLevel, maxLineLength) { + const parameterNames = data.parameters.map(item => `$${item.name}`); + + const hasReturnValue = this.hasReturnValue(data); + + let parentCallStatement = ''; + + if (hasReturnValue) { + parentCallStatement += '$value = '; + } + + parentCallStatement += `parent::${data.name}(`; + parentCallStatement += parameterNames.join(', '); + parentCallStatement += ');'; + + const statements = [ + parentCallStatement, + '', + '// TODO' + ]; + + if (hasReturnValue) { + statements.push(''); + statements.push('return $value;'); + } + + const functionText = this.functionBuilder + .setFromRawMethodData(data) + .setIsAbstract(false) + .setStatements(statements) + .setTabText(tabText) + .setIndentationLevel(indentationLevel) + .setMaxLineLength(maxLineLength) + .build(); + + const docblockText = this.docblockBuilder.buildByLines(['@inheritDoc'], tabText.repeat(indentationLevel)); + + return docblockText + functionText; + } + + /** + * @param {Object} data + * + * @return {Boolean} + */ + hasReturnValue(data) { + if (data.name === '__construct') { return false; } + if (data.returnTypes.length === 0) { return false; } + if ((data.returnTypes.length === 1) && (data.returnTypes[0].type === 'void')) { return false; } + + return true; + } + + /** + * @return {Builder} + */ + getSelectionView() { + if ((this.selectionView == null)) { + const View = require('./OverrideMethodProvider/View'); + + this.selectionView = new View(this.onConfirm.bind(this), this.onCancel.bind(this)); + this.selectionView.setLoading('Loading class information...'); + this.selectionView.setEmptyMessage('No overridable methods found.'); + } + + return this.selectionView; + } + }; + OverrideMethodProvider.initClass(); + return OverrideMethodProvider; +})()); diff --git a/lib/Refactoring/OverrideMethodProvider/View.coffee b/lib/Refactoring/OverrideMethodProvider/View.coffee deleted file mode 100644 index e44f4d06..00000000 --- a/lib/Refactoring/OverrideMethodProvider/View.coffee +++ /dev/null @@ -1,35 +0,0 @@ -{$, $$, SelectListView} = require 'atom-space-pen-views' - -MultiSelectionView = require '../Utility/MultiSelectionView.coffee' - -module.exports = - -##* -# An extension on SelectListView from atom-space-pen-views that allows multiple selections. -## -class View extends MultiSelectionView - ###* - * @inheritdoc - ### - createWidgets: () -> - checkboxBar = $$ -> - @div class: 'checkbox-bar settings-view', => - @div class: 'controls', => - @div class: 'block text-line', => - @label class: 'icon icon-info', 'Tip: The order in which items are selected determines the order of the output.' - - checkboxBar.appendTo(this) - - # Ensure that button clicks are actually handled. - @on 'mousedown', ({target}) => - return false if $(target).hasClass('checkbox-input') - return false if $(target).hasClass('checkbox-label-text') - - super() - - ###* - * @inheritdoc - ### - invokeOnDidConfirm: () -> - if @onDidConfirm - @onDidConfirm(@selectedItems, @getMetadata()) diff --git a/lib/Refactoring/OverrideMethodProvider/View.js b/lib/Refactoring/OverrideMethodProvider/View.js new file mode 100644 index 00000000..3da5d91a --- /dev/null +++ b/lib/Refactoring/OverrideMethodProvider/View.js @@ -0,0 +1,50 @@ +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let View; +const {$, $$, SelectListView} = require('atom-space-pen-views'); + +const MultiSelectionView = require('../Utility/MultiSelectionView'); + +module.exports = + +//#* +// An extension on SelectListView from atom-space-pen-views that allows multiple selections. +//# +(View = class View extends MultiSelectionView { + /** + * @inheritdoc + */ + createWidgets() { + const checkboxBar = $$(function() { + return this.div({class: 'checkbox-bar settings-view'}, () => { + return this.div({class: 'controls'}, () => { + return this.div({class: 'block text-line'}, () => { + return this.label({class: 'icon icon-info'}, 'Tip: The order in which items are selected determines the order of the output.'); + }); + }); + }); + }); + + checkboxBar.appendTo(this); + + // Ensure that button clicks are actually handled. + this.on('mousedown', ({target}) => { + if ($(target).hasClass('checkbox-input')) { return false; } + if ($(target).hasClass('checkbox-label-text')) { return false; } + }); + + return super.createWidgets(); + } + + /** + * @inheritdoc + */ + invokeOnDidConfirm() { + if (this.onDidConfirm) { + return this.onDidConfirm(this.selectedItems, this.getMetadata()); + } + } +}); diff --git a/lib/Refactoring/StubAbstractMethodProvider.coffee b/lib/Refactoring/StubAbstractMethodProvider.coffee deleted file mode 100644 index 39834277..00000000 --- a/lib/Refactoring/StubAbstractMethodProvider.coffee +++ /dev/null @@ -1,169 +0,0 @@ -AbstractProvider = require './AbstractProvider' - -module.exports = - -##* -# Provides the ability to stub abstract methods. -## -class StubAbstractMethodProvider extends AbstractProvider - ###* - * The view that allows the user to select the properties to generate for. - ### - selectionView: null - - ###* - * @type {Object} - ### - docblockBuilder: null - - ###* - * @type {Object} - ### - functionBuilder: null - - ###* - * @param {Object} docblockBuilder - * @param {Object} functionBuilder - ### - constructor: (@docblockBuilder, @functionBuilder) -> - - ###* - * @inheritdoc - ### - deactivate: () -> - super() - - if @selectionView - @selectionView.destroy() - @selectionView = null - - ###* - * @inheritdoc - ### - getIntentionProviders: () -> - return [{ - grammarScopes: ['source.php'] - getIntentions: ({textEditor, bufferPosition}) => - return [] if not @getCurrentProjectPhpVersion()? - - return @getStubInterfaceMethodIntentions(textEditor, bufferPosition) - }] - - ###* - * @param {TextEditor} editor - * @param {Point} triggerPosition - ### - getStubInterfaceMethodIntentions: (editor, triggerPosition) -> - failureHandler = () -> - return [] - - successHandler = (currentClassName) => - return [] if not currentClassName - - nestedSuccessHandler = (classInfo) => - return [] if not classInfo - - items = [] - - for name, method of classInfo.methods - data = { - name : name - method : method - } - - if method.isAbstract - items.push(data) - - return [] if items.length == 0 - - @getSelectionView().setItems(items) - - return [ - { - priority : 100 - icon : 'link' - title : 'Stub Unimplemented Abstract Method(s)' - - selected : () => - @executeStubInterfaceMethods(editor) - } - ] - - return @service.getClassInfo(currentClassName).then(nestedSuccessHandler, failureHandler) - - return @service.determineCurrentClassName(editor, triggerPosition).then(successHandler, failureHandler) - - ###* - * @param {TextEditor} editor - * @param {Point} triggerPosition - ### - executeStubInterfaceMethods: (editor) -> - @getSelectionView().setMetadata({editor: editor}) - @getSelectionView().storeFocusedElement() - @getSelectionView().present() - - ###* - * Called when the selection of properties is cancelled. - ### - onCancel: (metadata) -> - - ###* - * Called when the selection of properties is confirmed. - * - * @param {array} selectedItems - * @param {Object|null} metadata - ### - onConfirm: (selectedItems, metadata) -> - itemOutputs = [] - - tabText = metadata.editor.getTabText() - indentationLevel = metadata.editor.indentationForBufferRow(metadata.editor.getCursorBufferPosition().row) - maxLineLength = atom.config.get('editor.preferredLineLength', metadata.editor.getLastCursor().getScopeDescriptor()) - - for item in selectedItems - itemOutputs.push(@generateStubForInterfaceMethod(item.method, tabText, indentationLevel, maxLineLength)) - - output = itemOutputs.join("\n").trim() - - metadata.editor.insertText(output) - - ###* - * Generates a stub for the specified selected data. - * - * @param {Object} data - * @param {String} tabText - * @param {Number} indentationLevel - * @param {Number} maxLineLength - * - * @return {string} - ### - generateStubForInterfaceMethod: (data, tabText, indentationLevel, maxLineLength) -> - statements = [ - "throw new \\LogicException('Not implemented'); // TODO" - ] - - functionText = @functionBuilder - .setFromRawMethodData(data) - .setIsAbstract(false) - .setStatements(statements) - .setTabText(tabText) - .setIndentationLevel(indentationLevel) - .setMaxLineLength(maxLineLength) - .build() - - docblockText = @docblockBuilder.buildByLines(['@inheritDoc'], tabText.repeat(indentationLevel)) - - return docblockText + functionText - - ###* - * @return {Builder} - ### - getSelectionView: () -> - if not @selectionView? - View = require './StubAbstractMethodProvider/View' - - @selectionView = new View(@onConfirm.bind(this), @onCancel.bind(this)) - @selectionView.setLoading('Loading class information...') - @selectionView.setEmptyMessage('No unimplemented abstract methods found.') - - return @selectionView diff --git a/lib/Refactoring/StubAbstractMethodProvider.js b/lib/Refactoring/StubAbstractMethodProvider.js new file mode 100644 index 00000000..c6677937 --- /dev/null +++ b/lib/Refactoring/StubAbstractMethodProvider.js @@ -0,0 +1,206 @@ +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS206: Consider reworking classes to avoid initClass + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let StubAbstractMethodProvider; +const AbstractProvider = require('./AbstractProvider'); + +module.exports = + +//#* +// Provides the ability to stub abstract methods. +//# +(StubAbstractMethodProvider = (function() { + StubAbstractMethodProvider = class StubAbstractMethodProvider extends AbstractProvider { + static initClass() { + /** + * The view that allows the user to select the properties to generate for. + */ + this.prototype.selectionView = null; + + /** + * @type {Object} + */ + this.prototype.docblockBuilder = null; + + /** + * @type {Object} + */ + this.prototype.functionBuilder = null; + } + + /** + * @param {Object} docblockBuilder + * @param {Object} functionBuilder + */ + constructor(docblockBuilder, functionBuilder) { + super(); + + this.docblockBuilder = docblockBuilder; + this.functionBuilder = functionBuilder; + } + + /** + * @inheritdoc + */ + deactivate() { + super.deactivate(); + + if (this.selectionView) { + this.selectionView.destroy(); + return this.selectionView = null; + } + } + + /** + * @inheritdoc + */ + getIntentionProviders() { + return [{ + grammarScopes: ['source.php'], + getIntentions: ({textEditor, bufferPosition}) => { + if ((this.getCurrentProjectPhpVersion() == null)) { return []; } + + return this.getStubInterfaceMethodIntentions(textEditor, bufferPosition); + } + }]; + } + + /** + * @param {TextEditor} editor + * @param {Point} triggerPosition + */ + getStubInterfaceMethodIntentions(editor, triggerPosition) { + const failureHandler = () => []; + + const successHandler = currentClassName => { + if (!currentClassName) { return []; } + + const nestedSuccessHandler = classInfo => { + if (!classInfo) { return []; } + + const items = []; + + for (let name in classInfo.methods) { + const method = classInfo.methods[name]; + const data = { + name, + method + }; + + if (method.isAbstract) { + items.push(data); + } + } + + if (items.length === 0) { return []; } + + this.getSelectionView().setItems(items); + + return [ + { + priority : 100, + icon : 'link', + title : 'Stub Unimplemented Abstract Method(s)', + + selected : () => { + return this.executeStubInterfaceMethods(editor); + } + } + ]; + }; + + return this.service.getClassInfo(currentClassName).then(nestedSuccessHandler, failureHandler); + }; + + return this.service.determineCurrentClassName(editor, triggerPosition).then(successHandler, failureHandler); + } + + /** + * @param {TextEditor} editor + * @param {Point} triggerPosition + */ + executeStubInterfaceMethods(editor) { + this.getSelectionView().setMetadata({editor}); + this.getSelectionView().storeFocusedElement(); + return this.getSelectionView().present(); + } + + /** + * Called when the selection of properties is cancelled. + */ + onCancel(metadata) {} + + /** + * Called when the selection of properties is confirmed. + * + * @param {array} selectedItems + * @param {Object|null} metadata + */ + onConfirm(selectedItems, metadata) { + const itemOutputs = []; + + const tabText = metadata.editor.getTabText(); + const indentationLevel = metadata.editor.indentationForBufferRow(metadata.editor.getCursorBufferPosition().row); + const maxLineLength = atom.config.get('editor.preferredLineLength', metadata.editor.getLastCursor().getScopeDescriptor()); + + for (let item of Array.from(selectedItems)) { + itemOutputs.push(this.generateStubForInterfaceMethod(item.method, tabText, indentationLevel, maxLineLength)); + } + + const output = itemOutputs.join("\n").trim(); + + return metadata.editor.insertText(output); + } + + /** + * Generates a stub for the specified selected data. + * + * @param {Object} data + * @param {String} tabText + * @param {Number} indentationLevel + * @param {Number} maxLineLength + * + * @return {string} + */ + generateStubForInterfaceMethod(data, tabText, indentationLevel, maxLineLength) { + const statements = [ + "throw new \\LogicException('Not implemented'); // TODO" + ]; + + const functionText = this.functionBuilder + .setFromRawMethodData(data) + .setIsAbstract(false) + .setStatements(statements) + .setTabText(tabText) + .setIndentationLevel(indentationLevel) + .setMaxLineLength(maxLineLength) + .build(); + + const docblockText = this.docblockBuilder.buildByLines(['@inheritDoc'], tabText.repeat(indentationLevel)); + + return docblockText + functionText; + } + + /** + * @return {Builder} + */ + getSelectionView() { + if ((this.selectionView == null)) { + const View = require('./StubAbstractMethodProvider/View'); + + this.selectionView = new View(this.onConfirm.bind(this), this.onCancel.bind(this)); + this.selectionView.setLoading('Loading class information...'); + this.selectionView.setEmptyMessage('No unimplemented abstract methods found.'); + } + + return this.selectionView; + } + }; + StubAbstractMethodProvider.initClass(); + return StubAbstractMethodProvider; +})()); diff --git a/lib/Refactoring/StubAbstractMethodProvider/View.coffee b/lib/Refactoring/StubAbstractMethodProvider/View.coffee deleted file mode 100644 index e44f4d06..00000000 --- a/lib/Refactoring/StubAbstractMethodProvider/View.coffee +++ /dev/null @@ -1,35 +0,0 @@ -{$, $$, SelectListView} = require 'atom-space-pen-views' - -MultiSelectionView = require '../Utility/MultiSelectionView.coffee' - -module.exports = - -##* -# An extension on SelectListView from atom-space-pen-views that allows multiple selections. -## -class View extends MultiSelectionView - ###* - * @inheritdoc - ### - createWidgets: () -> - checkboxBar = $$ -> - @div class: 'checkbox-bar settings-view', => - @div class: 'controls', => - @div class: 'block text-line', => - @label class: 'icon icon-info', 'Tip: The order in which items are selected determines the order of the output.' - - checkboxBar.appendTo(this) - - # Ensure that button clicks are actually handled. - @on 'mousedown', ({target}) => - return false if $(target).hasClass('checkbox-input') - return false if $(target).hasClass('checkbox-label-text') - - super() - - ###* - * @inheritdoc - ### - invokeOnDidConfirm: () -> - if @onDidConfirm - @onDidConfirm(@selectedItems, @getMetadata()) diff --git a/lib/Refactoring/StubAbstractMethodProvider/View.js b/lib/Refactoring/StubAbstractMethodProvider/View.js new file mode 100644 index 00000000..3da5d91a --- /dev/null +++ b/lib/Refactoring/StubAbstractMethodProvider/View.js @@ -0,0 +1,50 @@ +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let View; +const {$, $$, SelectListView} = require('atom-space-pen-views'); + +const MultiSelectionView = require('../Utility/MultiSelectionView'); + +module.exports = + +//#* +// An extension on SelectListView from atom-space-pen-views that allows multiple selections. +//# +(View = class View extends MultiSelectionView { + /** + * @inheritdoc + */ + createWidgets() { + const checkboxBar = $$(function() { + return this.div({class: 'checkbox-bar settings-view'}, () => { + return this.div({class: 'controls'}, () => { + return this.div({class: 'block text-line'}, () => { + return this.label({class: 'icon icon-info'}, 'Tip: The order in which items are selected determines the order of the output.'); + }); + }); + }); + }); + + checkboxBar.appendTo(this); + + // Ensure that button clicks are actually handled. + this.on('mousedown', ({target}) => { + if ($(target).hasClass('checkbox-input')) { return false; } + if ($(target).hasClass('checkbox-label-text')) { return false; } + }); + + return super.createWidgets(); + } + + /** + * @inheritdoc + */ + invokeOnDidConfirm() { + if (this.onDidConfirm) { + return this.onDidConfirm(this.selectedItems, this.getMetadata()); + } + } +}); diff --git a/lib/Refactoring/StubInterfaceMethodProvider.coffee b/lib/Refactoring/StubInterfaceMethodProvider.coffee deleted file mode 100644 index eddac21e..00000000 --- a/lib/Refactoring/StubInterfaceMethodProvider.coffee +++ /dev/null @@ -1,168 +0,0 @@ -AbstractProvider = require './AbstractProvider' - -module.exports = - -##* -# Provides the ability to stub interface methods. -## -class StubInterfaceMethodProvider extends AbstractProvider - ###* - * The view that allows the user to select the properties to generate for. - ### - selectionView: null - - ###* - * @type {Object} - ### - docblockBuilder: null - - ###* - * @type {Object} - ### - functionBuilder: null - - ###* - * @param {Object} docblockBuilder - * @param {Object} functionBuilder - ### - constructor: (@docblockBuilder, @functionBuilder) -> - - ###* - * @inheritdoc - ### - deactivate: () -> - super() - - if @selectionView - @selectionView.destroy() - @selectionView = null - - ###* - * @inheritdoc - ### - getIntentionProviders: () -> - return [{ - grammarScopes: ['source.php'] - getIntentions: ({textEditor, bufferPosition}) => - return [] if not @getCurrentProjectPhpVersion()? - - return @getStubInterfaceMethodIntentions(textEditor, bufferPosition) - }] - - ###* - * @param {TextEditor} editor - * @param {Point} triggerPosition - ### - getStubInterfaceMethodIntentions: (editor, triggerPosition) -> - failureHandler = () -> - return [] - - successHandler = (currentClassName) => - return [] if not currentClassName - - nestedSuccessHandler = (classInfo) => - return [] if not classInfo - - items = [] - - for name, method of classInfo.methods - data = { - name : name - method : method - } - - if method.declaringStructure.type == 'interface' and method.implementations?.length == 0 - items.push(data) - - return [] if items.length == 0 - - @getSelectionView().setItems(items) - - return [ - { - priority : 100 - icon : 'link' - title : 'Stub Unimplemented Interface Method(s)' - - selected : () => - @executeStubInterfaceMethods(editor) - } - ] - - return @service.getClassInfo(currentClassName).then(nestedSuccessHandler, failureHandler) - - return @service.determineCurrentClassName(editor, triggerPosition).then(successHandler, failureHandler) - - ###* - * @param {TextEditor} editor - * @param {Point} triggerPosition - ### - executeStubInterfaceMethods: (editor) -> - @getSelectionView().setMetadata({editor: editor}) - @getSelectionView().storeFocusedElement() - @getSelectionView().present() - - ###* - * Called when the selection of properties is cancelled. - ### - onCancel: (metadata) -> - - ###* - * Called when the selection of properties is confirmed. - * - * @param {array} selectedItems - * @param {Object|null} metadata - ### - onConfirm: (selectedItems, metadata) -> - itemOutputs = [] - - tabText = metadata.editor.getTabText() - indentationLevel = metadata.editor.indentationForBufferRow(metadata.editor.getCursorBufferPosition().row) - maxLineLength = atom.config.get('editor.preferredLineLength', metadata.editor.getLastCursor().getScopeDescriptor()) - - for item in selectedItems - itemOutputs.push(@generateStubForInterfaceMethod(item.method, tabText, indentationLevel, maxLineLength)) - - output = itemOutputs.join("\n").trim() - - metadata.editor.insertText(output) - - ###* - * Generates a stub for the specified selected data. - * - * @param {Object} data - * @param {String} tabText - * @param {Number} indentationLevel - * @param {Number} maxLineLength - * - * @return {string} - ### - generateStubForInterfaceMethod: (data, tabText, indentationLevel, maxLineLength) -> - statements = [ - "throw new \\LogicException('Not implemented'); // TODO" - ] - - functionText = @functionBuilder - .setFromRawMethodData(data) - .setStatements(statements) - .setTabText(tabText) - .setIndentationLevel(indentationLevel) - .setMaxLineLength(maxLineLength) - .build() - - docblockText = @docblockBuilder.buildByLines(['@inheritDoc'], tabText.repeat(indentationLevel)) - - return docblockText + functionText - - ###* - * @return {Builder} - ### - getSelectionView: () -> - if not @selectionView? - View = require './StubInterfaceMethodProvider/View' - - @selectionView = new View(@onConfirm.bind(this), @onCancel.bind(this)) - @selectionView.setLoading('Loading class information...') - @selectionView.setEmptyMessage('No unimplemented interface methods found.') - - return @selectionView diff --git a/lib/Refactoring/StubInterfaceMethodProvider.js b/lib/Refactoring/StubInterfaceMethodProvider.js new file mode 100644 index 00000000..e31e67e8 --- /dev/null +++ b/lib/Refactoring/StubInterfaceMethodProvider.js @@ -0,0 +1,205 @@ +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS206: Consider reworking classes to avoid initClass + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let StubInterfaceMethodProvider; +const AbstractProvider = require('./AbstractProvider'); + +module.exports = + +//#* +// Provides the ability to stub interface methods. +//# +(StubInterfaceMethodProvider = (function() { + StubInterfaceMethodProvider = class StubInterfaceMethodProvider extends AbstractProvider { + static initClass() { + /** + * The view that allows the user to select the properties to generate for. + */ + this.prototype.selectionView = null; + + /** + * @type {Object} + */ + this.prototype.docblockBuilder = null; + + /** + * @type {Object} + */ + this.prototype.functionBuilder = null; + } + + /** + * @param {Object} docblockBuilder + * @param {Object} functionBuilder + */ + constructor(docblockBuilder, functionBuilder) { + super(); + + this.docblockBuilder = docblockBuilder; + this.functionBuilder = functionBuilder; + } + + /** + * @inheritdoc + */ + deactivate() { + super.deactivate(); + + if (this.selectionView) { + this.selectionView.destroy(); + return this.selectionView = null; + } + } + + /** + * @inheritdoc + */ + getIntentionProviders() { + return [{ + grammarScopes: ['source.php'], + getIntentions: ({textEditor, bufferPosition}) => { + if ((this.getCurrentProjectPhpVersion() == null)) { return []; } + + return this.getStubInterfaceMethodIntentions(textEditor, bufferPosition); + } + }]; + } + + /** + * @param {TextEditor} editor + * @param {Point} triggerPosition + */ + getStubInterfaceMethodIntentions(editor, triggerPosition) { + const failureHandler = () => []; + + const successHandler = currentClassName => { + if (!currentClassName) { return []; } + + const nestedSuccessHandler = classInfo => { + if (!classInfo) { return []; } + + const items = []; + + for (let name in classInfo.methods) { + const method = classInfo.methods[name]; + const data = { + name, + method + }; + + if ((method.declaringStructure.type === 'interface') && ((method.implementations != null ? method.implementations.length : undefined) === 0)) { + items.push(data); + } + } + + if (items.length === 0) { return []; } + + this.getSelectionView().setItems(items); + + return [ + { + priority : 100, + icon : 'link', + title : 'Stub Unimplemented Interface Method(s)', + + selected : () => { + return this.executeStubInterfaceMethods(editor); + } + } + ]; + }; + + return this.service.getClassInfo(currentClassName).then(nestedSuccessHandler, failureHandler); + }; + + return this.service.determineCurrentClassName(editor, triggerPosition).then(successHandler, failureHandler); + } + + /** + * @param {TextEditor} editor + * @param {Point} triggerPosition + */ + executeStubInterfaceMethods(editor) { + this.getSelectionView().setMetadata({editor}); + this.getSelectionView().storeFocusedElement(); + return this.getSelectionView().present(); + } + + /** + * Called when the selection of properties is cancelled. + */ + onCancel(metadata) {} + + /** + * Called when the selection of properties is confirmed. + * + * @param {array} selectedItems + * @param {Object|null} metadata + */ + onConfirm(selectedItems, metadata) { + const itemOutputs = []; + + const tabText = metadata.editor.getTabText(); + const indentationLevel = metadata.editor.indentationForBufferRow(metadata.editor.getCursorBufferPosition().row); + const maxLineLength = atom.config.get('editor.preferredLineLength', metadata.editor.getLastCursor().getScopeDescriptor()); + + for (let item of Array.from(selectedItems)) { + itemOutputs.push(this.generateStubForInterfaceMethod(item.method, tabText, indentationLevel, maxLineLength)); + } + + const output = itemOutputs.join("\n").trim(); + + return metadata.editor.insertText(output); + } + + /** + * Generates a stub for the specified selected data. + * + * @param {Object} data + * @param {String} tabText + * @param {Number} indentationLevel + * @param {Number} maxLineLength + * + * @return {string} + */ + generateStubForInterfaceMethod(data, tabText, indentationLevel, maxLineLength) { + const statements = [ + "throw new \\LogicException('Not implemented'); // TODO" + ]; + + const functionText = this.functionBuilder + .setFromRawMethodData(data) + .setStatements(statements) + .setTabText(tabText) + .setIndentationLevel(indentationLevel) + .setMaxLineLength(maxLineLength) + .build(); + + const docblockText = this.docblockBuilder.buildByLines(['@inheritDoc'], tabText.repeat(indentationLevel)); + + return docblockText + functionText; + } + + /** + * @return {Builder} + */ + getSelectionView() { + if ((this.selectionView == null)) { + const View = require('./StubInterfaceMethodProvider/View'); + + this.selectionView = new View(this.onConfirm.bind(this), this.onCancel.bind(this)); + this.selectionView.setLoading('Loading class information...'); + this.selectionView.setEmptyMessage('No unimplemented interface methods found.'); + } + + return this.selectionView; + } + }; + StubInterfaceMethodProvider.initClass(); + return StubInterfaceMethodProvider; +})()); diff --git a/lib/Refactoring/StubInterfaceMethodProvider/View.coffee b/lib/Refactoring/StubInterfaceMethodProvider/View.coffee deleted file mode 100644 index e44f4d06..00000000 --- a/lib/Refactoring/StubInterfaceMethodProvider/View.coffee +++ /dev/null @@ -1,35 +0,0 @@ -{$, $$, SelectListView} = require 'atom-space-pen-views' - -MultiSelectionView = require '../Utility/MultiSelectionView.coffee' - -module.exports = - -##* -# An extension on SelectListView from atom-space-pen-views that allows multiple selections. -## -class View extends MultiSelectionView - ###* - * @inheritdoc - ### - createWidgets: () -> - checkboxBar = $$ -> - @div class: 'checkbox-bar settings-view', => - @div class: 'controls', => - @div class: 'block text-line', => - @label class: 'icon icon-info', 'Tip: The order in which items are selected determines the order of the output.' - - checkboxBar.appendTo(this) - - # Ensure that button clicks are actually handled. - @on 'mousedown', ({target}) => - return false if $(target).hasClass('checkbox-input') - return false if $(target).hasClass('checkbox-label-text') - - super() - - ###* - * @inheritdoc - ### - invokeOnDidConfirm: () -> - if @onDidConfirm - @onDidConfirm(@selectedItems, @getMetadata()) diff --git a/lib/Refactoring/StubInterfaceMethodProvider/View.js b/lib/Refactoring/StubInterfaceMethodProvider/View.js new file mode 100644 index 00000000..3da5d91a --- /dev/null +++ b/lib/Refactoring/StubInterfaceMethodProvider/View.js @@ -0,0 +1,50 @@ +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let View; +const {$, $$, SelectListView} = require('atom-space-pen-views'); + +const MultiSelectionView = require('../Utility/MultiSelectionView'); + +module.exports = + +//#* +// An extension on SelectListView from atom-space-pen-views that allows multiple selections. +//# +(View = class View extends MultiSelectionView { + /** + * @inheritdoc + */ + createWidgets() { + const checkboxBar = $$(function() { + return this.div({class: 'checkbox-bar settings-view'}, () => { + return this.div({class: 'controls'}, () => { + return this.div({class: 'block text-line'}, () => { + return this.label({class: 'icon icon-info'}, 'Tip: The order in which items are selected determines the order of the output.'); + }); + }); + }); + }); + + checkboxBar.appendTo(this); + + // Ensure that button clicks are actually handled. + this.on('mousedown', ({target}) => { + if ($(target).hasClass('checkbox-input')) { return false; } + if ($(target).hasClass('checkbox-label-text')) { return false; } + }); + + return super.createWidgets(); + } + + /** + * @inheritdoc + */ + invokeOnDidConfirm() { + if (this.onDidConfirm) { + return this.onDidConfirm(this.selectedItems, this.getMetadata()); + } + } +}); diff --git a/lib/Refactoring/Utility/DocblockBuilder.coffee b/lib/Refactoring/Utility/DocblockBuilder.coffee deleted file mode 100644 index 37c0926b..00000000 --- a/lib/Refactoring/Utility/DocblockBuilder.coffee +++ /dev/null @@ -1,110 +0,0 @@ -module.exports = - -class DocblockBuilder - ###* - * @param {Array} parameters - * @param {String|null} returnType - * @param {boolean} generateDescriptionPlaceholders - * @param {String} tabText - * - * @return {String} - ### - buildForMethod: (parameters, returnType, generateDescriptionPlaceholders = true, tabText = '') => - lines = [] - - if generateDescriptionPlaceholders - lines.push("[Short description of the method]") - - if parameters.length > 0 - descriptionPlaceholder = "" - - if generateDescriptionPlaceholders - lines.push('') - - descriptionPlaceholder = " [Description]" - - # Determine the necessary padding. - parameterTypeLengths = parameters.map (item) -> - return if item.type then item.type.length else 0 - - parameterNameLengths = parameters.map (item) -> - return if item.name then item.name.length else 0 - - longestTypeLength = Math.max(parameterTypeLengths...) - longestNameLength = Math.max(parameterNameLengths...) - - # Generate parameter lines. - for parameter in parameters - typePadding = longestTypeLength - parameter.type.length - variablePadding = longestNameLength - parameter.name.length - - type = parameter.type + ' '.repeat(typePadding) - variable = parameter.name + ' '.repeat(variablePadding) - - lines.push("@param #{type} #{variable}#{descriptionPlaceholder}") - - if returnType? and returnType != 'void' - if generateDescriptionPlaceholders or parameters.length > 0 - lines.push('') - - lines.push("@return #{returnType}") - - return @buildByLines(lines, tabText) - - ###* - * @param {String|null} type - * @param {boolean} generateDescriptionPlaceholders - * @param {String} tabText - * - * @return {String} - ### - buildForProperty: (type, generateDescriptionPlaceholders = true, tabText = '') => - lines = [] - - if generateDescriptionPlaceholders - lines.push("[Short description of the property]") - lines.push('') - - lines.push("@var #{type}") - - return @buildByLines(lines, tabText) - - ###* - * @param {Array} lines - * @param {String} tabText - * - * @return {String} - ### - buildByLines: (lines, tabText = '') => - docs = @buildLine("/**", tabText) - - if lines.length == 0 - # Ensure we always have at least one line. - lines.push('') - - for line in lines - docs += @buildDocblockLine(line, tabText) - - docs += @buildLine(" */", tabText) - - return docs - - ###* - * @param {String} content - * @param {String} tabText - * - * @return {String} - ### - buildDocblockLine: (content, tabText = '') -> - content = " * #{content}" - - return @buildLine(content.trimRight(), tabText) - - ###* - * @param {String} content - * @param {String} tabText - * - * @return {String} - ### - buildLine: (content, tabText = '') -> - return "#{tabText}#{content}\n" diff --git a/lib/Refactoring/Utility/DocblockBuilder.js b/lib/Refactoring/Utility/DocblockBuilder.js new file mode 100644 index 00000000..3428125d --- /dev/null +++ b/lib/Refactoring/Utility/DocblockBuilder.js @@ -0,0 +1,148 @@ +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let DocblockBuilder; +module.exports = + +(DocblockBuilder = class DocblockBuilder { + /** + * @param {Array} parameters + * @param {String|null} returnType + * @param {boolean} generateDescriptionPlaceholders + * @param {String} tabText + * + * @return {String} + */ + constructor() { + this.buildForMethod = this.buildForMethod.bind(this); + this.buildForProperty = this.buildForProperty.bind(this); + this.buildByLines = this.buildByLines.bind(this); + } + + buildForMethod(parameters, returnType, generateDescriptionPlaceholders, tabText) { + if (generateDescriptionPlaceholders == null) { generateDescriptionPlaceholders = true; } + if (tabText == null) { tabText = ''; } + const lines = []; + + if (generateDescriptionPlaceholders) { + lines.push("[Short description of the method]"); + } + + if (parameters.length > 0) { + let descriptionPlaceholder = ""; + + if (generateDescriptionPlaceholders) { + lines.push(''); + + descriptionPlaceholder = " [Description]"; + } + + // Determine the necessary padding. + const parameterTypeLengths = parameters.map(function(item) { + if (item.type) { return item.type.length; } else { return 0; } + }); + + const parameterNameLengths = parameters.map(function(item) { + if (item.name) { return item.name.length; } else { return 0; } + }); + + const longestTypeLength = Math.max(...Array.from(parameterTypeLengths || [])); + const longestNameLength = Math.max(...Array.from(parameterNameLengths || [])); + + // Generate parameter lines. + for (let parameter of Array.from(parameters)) { + const typePadding = longestTypeLength - parameter.type.length; + const variablePadding = longestNameLength - parameter.name.length; + + const type = parameter.type + ' '.repeat(typePadding); + const variable = parameter.name + ' '.repeat(variablePadding); + + lines.push(`@param ${type} ${variable}${descriptionPlaceholder}`); + } + } + + if ((returnType != null) && (returnType !== 'void')) { + if (generateDescriptionPlaceholders || (parameters.length > 0)) { + lines.push(''); + } + + lines.push(`@return ${returnType}`); + } + + return this.buildByLines(lines, tabText); + } + + /** + * @param {String|null} type + * @param {boolean} generateDescriptionPlaceholders + * @param {String} tabText + * + * @return {String} + */ + buildForProperty(type, generateDescriptionPlaceholders, tabText) { + if (generateDescriptionPlaceholders == null) { generateDescriptionPlaceholders = true; } + if (tabText == null) { tabText = ''; } + const lines = []; + + if (generateDescriptionPlaceholders) { + lines.push("[Short description of the property]"); + lines.push(''); + } + + lines.push(`@var ${type}`); + + return this.buildByLines(lines, tabText); + } + + /** + * @param {Array} lines + * @param {String} tabText + * + * @return {String} + */ + buildByLines(lines, tabText) { + if (tabText == null) { tabText = ''; } + let docs = this.buildLine("/**", tabText); + + if (lines.length === 0) { + // Ensure we always have at least one line. + lines.push(''); + } + + for (let line of Array.from(lines)) { + docs += this.buildDocblockLine(line, tabText); + } + + docs += this.buildLine(" */", tabText); + + return docs; + } + + /** + * @param {String} content + * @param {String} tabText + * + * @return {String} + */ + buildDocblockLine(content, tabText) { + if (tabText == null) { tabText = ''; } + content = ` * ${content}`; + + return this.buildLine(content.trimRight(), tabText); + } + + /** + * @param {String} content + * @param {String} tabText + * + * @return {String} + */ + buildLine(content, tabText) { + if (tabText == null) { tabText = ''; } + return `${tabText}${content}\n`; + } +}); diff --git a/lib/Refactoring/Utility/FunctionBuilder.coffee b/lib/Refactoring/Utility/FunctionBuilder.coffee deleted file mode 100644 index 94e68b55..00000000 --- a/lib/Refactoring/Utility/FunctionBuilder.coffee +++ /dev/null @@ -1,362 +0,0 @@ -module.exports = - -class FunctionBuilder - ###* - * The access modifier (null if none). - ### - accessModifier: null - - ###* - * Whether the method is static or not. - ### - isStatic: false - - ###* - * Whether the method is abstract or not. - ### - isAbstract: null - - ###* - * The name of the function. - ### - name: null - - ###* - * The return type of the function. This could be set when generating PHP >= 7 methods. - ### - returnType: null - - ###* - * The parameters of the function (a list of objects). - ### - parameters: null - - ###* - * A list of statements to place in the body of the function. - ### - statements: null - - ###* - * The tab text to insert on each line. - ### - tabText: '' - - ###* - * The indentation level. - ### - indentationLevel: null - - ###* - * The indentation level. - * - * @var {Number|null} - ### - maxLineLength: null - - ###* - * Constructor. - ### - constructor: () -> - @parameters = [] - @statements = [] - - ###* - * Makes the method public. - * - * @return {FunctionBuilder} - ### - makePublic: () -> - @accessModifier = 'public' - return this - - ###* - * Makes the method private. - * - * @return {FunctionBuilder} - ### - makePrivate: () -> - @accessModifier = 'private' - return this - - ###* - * Makes the method protected. - * - * @return {FunctionBuilder} - ### - makeProtected: () -> - @accessModifier = 'protected' - return this - - ###* - * Makes the method global (i.e. no access modifier is added). - * - * @return {FunctionBuilder} - ### - makeGlobal: () -> - @accessModifier = null - return this - - ###* - * Sets whether the method is static or not. - * - * @param {bool} isStatic - * - * @return {FunctionBuilder} - ### - setIsStatic: (@isStatic) -> - return this - - ###* - * Sets whether the method is abstract or not. - * - * @param {bool} isAbstract - * - * @return {FunctionBuilder} - ### - setIsAbstract: (@isAbstract) -> - return this - - ###* - * Sets the name of the function. - * - * @param {String} name - * - * @return {FunctionBuilder} - ### - setName: (@name) -> - return this - - ###* - * Sets the return type. - * - * @param {String|null} returnType - * - * @return {FunctionBuilder} - ### - setReturnType: (@returnType) -> - return this - - ###* - * Sets the parameters to add. - * - * @param {Array} parameters - * - * @return {FunctionBuilder} - ### - setParameters: (@parameters) -> - return this - - ###* - * Adds a parameter to the parameter list. - * - * @param {Object} parameter - * - * @return {FunctionBuilder} - ### - addParameter: (parameter) -> - @parameters.push(parameter) - return this - - ###* - * Sets the statements to add. - * - * @param {Array} statements - * - * @return {FunctionBuilder} - ### - setStatements: (@statements) -> - return this - - ###* - * Adds a statement to the body of the function. - * - * @param {String} statement - * - * @return {FunctionBuilder} - ### - addStatement: (statement) -> - @statements.push(statement) - return this - - ###* - * Sets the tab text to prepend to each line. - * - * @param {String} tabText - * - * @return {FunctionBuilder} - ### - setTabText: (@tabText) -> - return this - - ###* - * Sets the indentation level to use. The tab text is repeated this many times for each line. - * - * @param {Number} indentationLevel - * - * @return {FunctionBuilder} - ### - setIndentationLevel: (@indentationLevel) -> - return this - - ###* - * Sets the maximum length a single line may occupy. After this, text will wrap. - * - * This primarily influences parameter lists, which will automatically be split over multiple lines if the parameter - * list would otherwise exceed the maximum length. - * - * @param {Number|null} maxLineLength The length or null to disable the maximum. - * - * @return {FunctionBuilder} - ### - setMaxLineLength: (@maxLineLength) -> - return this - - ###* - * Sets the parameters of the builder based on raw method data from the base service. - * - * @param {Object} data - * - * @return {FunctionBuilder} - ### - setFromRawMethodData: (data) -> - if data.isPublic - @makePublic() - - else if data.isProtected - @makeProtected() - - else if data.isPrivate - @makePrivate() - - else - @makeGlobal() - - @setName(data.name) - @setIsStatic(data.isStatic) - @setIsAbstract(data.isAbstract) - @setReturnType(data.returnTypeHint) - - parameters = [] - - for parameter in data.parameters - parameters.push({ - name : '$' + parameter.name - typeHint : parameter.typeHint - isVariadic : parameter.isVariadic - isReference : parameter.isReference - defaultValue : parameter.defaultValue - }) - - @setParameters(parameters) - - return this - - ###* - * Builds the method using the preconfigured settings. - * - * @return {String} - ### - build: () => - output = '' - - signature = @buildSignature(false) - - if @maxLineLength? and signature.length > @maxLineLength - output += @buildSignature(true) - output += " {\n" - - else - output += signature + "\n" - output += @buildLine('{') - - for statement in @statements - output += @tabText + @buildLine(statement) - - output += @buildLine('}') - - return output - - ###* - * @param {Boolean} isMultiLine - * - * @return {String} - ### - buildSignature: (isMultiLine) -> - signatureLine = '' - - if @isAbstract - signatureLine += 'abstract ' - - if @accessModifier? - signatureLine += "#{@accessModifier} " - - if @isStatic - signatureLine += 'static ' - - signatureLine += "function #{@name}(" - - parameters = [] - - for parameter in @parameters - parameterText = '' - - if parameter.typeHint? - parameterText += "#{parameter.typeHint} " - - if parameter.isVariadic - parameterText += '...' - - if parameter.isReference - parameterText += '&' - - parameterText += "#{parameter.name}" - - if parameter.defaultValue? - parameterText += " = #{parameter.defaultValue}" - - parameters.push(parameterText) - - if not isMultiLine - signatureLine += parameters.join(', ') - signatureLine += ')' - - signatureLine = @addTabText(signatureLine) - - else - signatureLine = @buildLine(signatureLine) - - for i, parameter of parameters - if i < (parameters.length - 1) - parameter += ',' - - signatureLine += @buildLine(parameter, @indentationLevel + 1) - - signatureLine += @addTabText(')') - - if @returnType? - signatureLine += ": #{@returnType}" - - return signatureLine - - ###* - * @param {String} content - * @param {Number|null} indentationLevel - * - * @return {String} - ### - buildLine: (content, indentationLevel = null) -> - return @addTabText(content, indentationLevel) + "\n" - - ###* - * @param {String} content - * @param {Number|null} indentationLevel - * - * @return {String} - ### - addTabText: (content, indentationLevel = null) -> - if not indentationLevel? - indentationLevel = @indentationLevel - - tabText = @tabText.repeat(indentationLevel) - - return "#{tabText}#{content}" diff --git a/lib/Refactoring/Utility/FunctionBuilder.js b/lib/Refactoring/Utility/FunctionBuilder.js new file mode 100644 index 00000000..365b3ecb --- /dev/null +++ b/lib/Refactoring/Utility/FunctionBuilder.js @@ -0,0 +1,426 @@ +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS206: Consider reworking classes to avoid initClass + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let FunctionBuilder; +module.exports = + +(FunctionBuilder = (function() { + FunctionBuilder = class FunctionBuilder { + static initClass() { + /** + * The access modifier (null if none). + */ + this.prototype.accessModifier = null; + + /** + * Whether the method is static or not. + */ + this.prototype.isStatic = false; + + /** + * Whether the method is abstract or not. + */ + this.prototype.isAbstract = null; + + /** + * The name of the function. + */ + this.prototype.name = null; + + /** + * The return type of the function. This could be set when generating PHP >= 7 methods. + */ + this.prototype.returnType = null; + + /** + * The parameters of the function (a list of objects). + */ + this.prototype.parameters = null; + + /** + * A list of statements to place in the body of the function. + */ + this.prototype.statements = null; + + /** + * The tab text to insert on each line. + */ + this.prototype.tabText = ''; + + /** + * The indentation level. + */ + this.prototype.indentationLevel = null; + + /** + * The indentation level. + * + * @var {Number|null} + */ + this.prototype.maxLineLength = null; + } + + /** + * Constructor. + */ + constructor() { + this.build = this.build.bind(this); + this.parameters = []; + this.statements = []; + } + + /** + * Makes the method public. + * + * @return {FunctionBuilder} + */ + makePublic() { + this.accessModifier = 'public'; + return this; + } + + /** + * Makes the method private. + * + * @return {FunctionBuilder} + */ + makePrivate() { + this.accessModifier = 'private'; + return this; + } + + /** + * Makes the method protected. + * + * @return {FunctionBuilder} + */ + makeProtected() { + this.accessModifier = 'protected'; + return this; + } + + /** + * Makes the method global (i.e. no access modifier is added). + * + * @return {FunctionBuilder} + */ + makeGlobal() { + this.accessModifier = null; + return this; + } + + /** + * Sets whether the method is static or not. + * + * @param {bool} isStatic + * + * @return {FunctionBuilder} + */ + setIsStatic(isStatic) { + this.isStatic = isStatic; + return this; + } + + /** + * Sets whether the method is abstract or not. + * + * @param {bool} isAbstract + * + * @return {FunctionBuilder} + */ + setIsAbstract(isAbstract) { + this.isAbstract = isAbstract; + return this; + } + + /** + * Sets the name of the function. + * + * @param {String} name + * + * @return {FunctionBuilder} + */ + setName(name) { + this.name = name; + return this; + } + + /** + * Sets the return type. + * + * @param {String|null} returnType + * + * @return {FunctionBuilder} + */ + setReturnType(returnType) { + this.returnType = returnType; + return this; + } + + /** + * Sets the parameters to add. + * + * @param {Array} parameters + * + * @return {FunctionBuilder} + */ + setParameters(parameters) { + this.parameters = parameters; + return this; + } + + /** + * Adds a parameter to the parameter list. + * + * @param {Object} parameter + * + * @return {FunctionBuilder} + */ + addParameter(parameter) { + this.parameters.push(parameter); + return this; + } + + /** + * Sets the statements to add. + * + * @param {Array} statements + * + * @return {FunctionBuilder} + */ + setStatements(statements) { + this.statements = statements; + return this; + } + + /** + * Adds a statement to the body of the function. + * + * @param {String} statement + * + * @return {FunctionBuilder} + */ + addStatement(statement) { + this.statements.push(statement); + return this; + } + + /** + * Sets the tab text to prepend to each line. + * + * @param {String} tabText + * + * @return {FunctionBuilder} + */ + setTabText(tabText) { + this.tabText = tabText; + return this; + } + + /** + * Sets the indentation level to use. The tab text is repeated this many times for each line. + * + * @param {Number} indentationLevel + * + * @return {FunctionBuilder} + */ + setIndentationLevel(indentationLevel) { + this.indentationLevel = indentationLevel; + return this; + } + + /** + * Sets the maximum length a single line may occupy. After this, text will wrap. + * + * This primarily influences parameter lists, which will automatically be split over multiple lines if the parameter + * list would otherwise exceed the maximum length. + * + * @param {Number|null} maxLineLength The length or null to disable the maximum. + * + * @return {FunctionBuilder} + */ + setMaxLineLength(maxLineLength) { + this.maxLineLength = maxLineLength; + return this; + } + + /** + * Sets the parameters of the builder based on raw method data from the base service. + * + * @param {Object} data + * + * @return {FunctionBuilder} + */ + setFromRawMethodData(data) { + if (data.isPublic) { + this.makePublic(); + + } else if (data.isProtected) { + this.makeProtected(); + + } else if (data.isPrivate) { + this.makePrivate(); + + } else { + this.makeGlobal(); + } + + this.setName(data.name); + this.setIsStatic(data.isStatic); + this.setIsAbstract(data.isAbstract); + this.setReturnType(data.returnTypeHint); + + const parameters = []; + + for (let parameter of Array.from(data.parameters)) { + parameters.push({ + name : `$${parameter.name}`, + typeHint : parameter.typeHint, + isVariadic : parameter.isVariadic, + isReference : parameter.isReference, + defaultValue : parameter.defaultValue + }); + } + + this.setParameters(parameters); + + return this; + } + + /** + * Builds the method using the preconfigured settings. + * + * @return {String} + */ + build() { + let output = ''; + + const signature = this.buildSignature(false); + + if ((this.maxLineLength != null) && (signature.length > this.maxLineLength)) { + output += this.buildSignature(true); + output += " {\n"; + + } else { + output += signature + "\n"; + output += this.buildLine('{'); + } + + for (let statement of Array.from(this.statements)) { + output += this.tabText + this.buildLine(statement); + } + + output += this.buildLine('}'); + + return output; + } + + /** + * @param {Boolean} isMultiLine + * + * @return {String} + */ + buildSignature(isMultiLine) { + let signatureLine = ''; + + if (this.isAbstract) { + signatureLine += 'abstract '; + } + + if (this.accessModifier != null) { + signatureLine += `${this.accessModifier} `; + } + + if (this.isStatic) { + signatureLine += 'static '; + } + + signatureLine += `function ${this.name}(`; + + const parameters = []; + + for (var parameter of Array.from(this.parameters)) { + let parameterText = ''; + + if (parameter.typeHint != null) { + parameterText += `${parameter.typeHint} `; + } + + if (parameter.isVariadic) { + parameterText += '...'; + } + + if (parameter.isReference) { + parameterText += '&'; + } + + parameterText += `${parameter.name}`; + + if (parameter.defaultValue != null) { + parameterText += ` = ${parameter.defaultValue}`; + } + + parameters.push(parameterText); + } + + if (!isMultiLine) { + signatureLine += parameters.join(', '); + signatureLine += ')'; + + signatureLine = this.addTabText(signatureLine); + + } else { + signatureLine = this.buildLine(signatureLine); + + for (let i in parameters) { + parameter = parameters[i]; + if (i < (parameters.length - 1)) { + parameter += ','; + } + + signatureLine += this.buildLine(parameter, this.indentationLevel + 1); + } + + signatureLine += this.addTabText(')'); + } + + if (this.returnType != null) { + signatureLine += `: ${this.returnType}`; + } + + return signatureLine; + } + + /** + * @param {String} content + * @param {Number|null} indentationLevel + * + * @return {String} + */ + buildLine(content, indentationLevel = null) { + return this.addTabText(content, indentationLevel) + "\n"; + } + + /** + * @param {String} content + * @param {Number|null} indentationLevel + * + * @return {String} + */ + addTabText(content, indentationLevel = null) { + if ((indentationLevel == null)) { + ({ indentationLevel } = this); + } + + const tabText = this.tabText.repeat(indentationLevel); + + return `${tabText}${content}`; + } + }; + FunctionBuilder.initClass(); + return FunctionBuilder; +})()); diff --git a/lib/Refactoring/Utility/MultiSelectionView.coffee b/lib/Refactoring/Utility/MultiSelectionView.coffee deleted file mode 100644 index f9c3c15d..00000000 --- a/lib/Refactoring/Utility/MultiSelectionView.coffee +++ /dev/null @@ -1,234 +0,0 @@ -{$, $$, SelectListView} = require 'atom-space-pen-views' - -module.exports = - -##* -# An extension on SelectListView from atom-space-pen-views that allows multiple selections. -## -class MultiSelectionView extends SelectListView - ###* - * The callback to invoke when the user confirms his selections. - ### - onDidConfirm : null - - ###* - * The callback to invoke when the user cancels the view. - ### - onDidCancel : null - - ###* - * Metadata to pass to the callbacks. - ### - metadata : null - - ###* - * The message to display when there are no results. - ### - emptyMessage : null - - ###* - * Items that are currently selected. - ### - selectedItems : null - - ###* - * Constructor. - * - * @param {Callback} onDidConfirm - * @param {Callback} onDidCancel - ### - constructor: (@onDidConfirm, @onDidCancel = null) -> - super() - - @selectedItems = [] - - ###* - * @inheritdoc - ### - initialize: -> - super() - - @addClass('php-ide-serenata-refactoring-multi-selection-view') - @list.addClass('mark-active') - - @panel ?= atom.workspace.addModalPanel(item: this, visible: false) - - @createWidgets() - - ###* - * Destroys the view and cleans up. - ### - destroy: () -> - @panel.destroy() - @panel = null - - ###* - * Creates additional for the view. - ### - createWidgets: () -> - cancelButtonText = @getCancelButtonText() - confirmButtonText = @getConfirmButtonText() - - buttonBar = $$ -> - @div class: 'button-bar', => - @button class: 'btn btn-error inline-block-tight pull-left icon icon-circle-slash button--cancel', cancelButtonText - @button class: 'btn btn-success inline-block-tight pull-right icon icon-gear button--confirm', confirmButtonText - @div class: 'clear-float' - - buttonBar.appendTo(this) - - @on 'click', 'button', (event) => - @confirmedByButton() if $(event.target).hasClass('button--confirm') - @cancel() if $(event.target).hasClass('button--cancel') - - @on 'keydown', (event) => - # Shift + Return - if event.keyCode == 13 and event.shiftKey == true - @confirmedByButton() - - # Ensure that button clicks are actually handled. - @on 'mousedown', ({target}) => - return false if $(target).hasClass('btn') - - ###* - * @inheritdoc - ### - viewForItem: (item) -> - classes = ['list-item'] - - if item.className - classes.push(item.className) - - if item.isSelected - classes.push('active') - - className = classes.join(' ') - displayText = item.name - - return """ -
  • #{displayText}
  • - """ - - ###* - * @inheritdoc - ### - getFilterKey: () -> - return 'name' - - ###* - * Retrieves the text to display on the cancel button. - * - * @return {string} - ### - getCancelButtonText: () -> - return 'Cancel' - - ###* - * Retrieves the text to display on the confirmation button. - * - * @return {string} - ### - getConfirmButtonText: () -> - return 'Generate' - - ###* - * Retrieves the message that is displayed when there are no results. - * - * @return {string} - ### - getEmptyMessage: () -> - if @emptyMessage? - return @emptyMessage - - return super() - - ###* - * Sets the message that is displayed when there are no results. - * - * @param {string} emptyMessage - ### - setEmptyMessage: (emptyMessage) -> - @emptyMessage = emptyMessage - - ###* - * Retrieves the metadata to pass to the callbacks. - * - * @return {Object|null} - ### - getMetadata: () -> - return @metadata - - ###* - * Sets the metadata to pass to the callbacks. - * - * @param {Object|null} metadata - ### - setMetadata: (metadata) -> - @metadata = metadata - - ###* - * @inheritdoc - ### - setItems: (items) -> - i = 0 - - for item in items - item.index = i++ - - super(items) - - @selectedItems = [] - - ###* - * @inheritdoc - ### - confirmed: (item) -> - item.isSelected = not item.isSelected - - if item.isSelected - @selectedItems.push(item) - - else - index = @selectedItems.indexOf(item) - - if index >= 0 - @selectedItems.splice(index, 1) - - selectedItem = @getSelectedItem() - index = if selectedItem then selectedItem.index else 0 - - @populateList() - - @selectItemView(@list.find("li:nth(#{index})")) - - ###* - * Invoked when the user confirms his selections by pressing the confirmation button. - ### - confirmedByButton: () -> - @invokeOnDidConfirm() - @restoreFocus() - @panel.hide() - - ###* - * Invokes the on did confirm handler with the correct arguments (if it is set). - ### - invokeOnDidConfirm: () -> - if @onDidConfirm - @onDidConfirm(@selectedItems, @getMetadata()) - - ###* - * @inheritdoc - ### - cancelled: () -> - if @onDidCancel - @onDidCancel(@getMetadata()) - - @restoreFocus() - @panel.hide() - - ###* - * Presents the view to the user. - ### - present: () -> - @panel.show() - @focusFilterEditor() diff --git a/lib/Refactoring/Utility/MultiSelectionView.js b/lib/Refactoring/Utility/MultiSelectionView.js new file mode 100644 index 00000000..20b7c63a --- /dev/null +++ b/lib/Refactoring/Utility/MultiSelectionView.js @@ -0,0 +1,286 @@ +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS206: Consider reworking classes to avoid initClass + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let MultiSelectionView; +const {$, $$, SelectListView} = require('atom-space-pen-views'); + +module.exports = + +//#* +// An extension on SelectListView from atom-space-pen-views that allows multiple selections. +//# +(MultiSelectionView = (function() { + MultiSelectionView = class MultiSelectionView extends SelectListView { + static initClass() { + /** + * The callback to invoke when the user confirms his selections. + */ + this.prototype.onDidConfirm = null; + + /** + * The callback to invoke when the user cancels the view. + */ + this.prototype.onDidCancel = null; + + /** + * Metadata to pass to the callbacks. + */ + this.prototype.metadata = null; + + /** + * The message to display when there are no results. + */ + this.prototype.emptyMessage = null; + + /** + * Items that are currently selected. + */ + this.prototype.selectedItems = null; + } + + /** + * Constructor. + * + * @param {Callback} onDidConfirm + * @param {Callback} onDidCancel + */ + constructor(onDidConfirm, onDidCancel = null) { + super(); + + this.onDidConfirm = onDidConfirm; + this.onDidCancel = onDidCancel; + + this.selectedItems = []; + } + + /** + * @inheritdoc + */ + initialize() { + super.initialize(); + + this.addClass('php-ide-serenata-refactoring-multi-selection-view'); + this.list.addClass('mark-active'); + + if (this.panel == null) { this.panel = atom.workspace.addModalPanel({item: this, visible: false}); } + + return this.createWidgets(); + } + + /** + * Destroys the view and cleans up. + */ + destroy() { + this.panel.destroy(); + return this.panel = null; + } + + /** + * Creates additional for the view. + */ + createWidgets() { + const cancelButtonText = this.getCancelButtonText(); + const confirmButtonText = this.getConfirmButtonText(); + + const buttonBar = $$(function() { + return this.div({class: 'button-bar'}, () => { + this.button({class: 'btn btn-error inline-block-tight pull-left icon icon-circle-slash button--cancel'}, cancelButtonText); + this.button({class: 'btn btn-success inline-block-tight pull-right icon icon-gear button--confirm'}, confirmButtonText); + return this.div({class: 'clear-float'}); + }); + }); + + buttonBar.appendTo(this); + + this.on('click', 'button', event => { + if ($(event.target).hasClass('button--confirm')) { this.confirmedByButton(); } + if ($(event.target).hasClass('button--cancel')) { return this.cancel(); } + }); + + this.on('keydown', event => { + // Shift + Return + if ((event.keyCode === 13) && (event.shiftKey === true)) { + return this.confirmedByButton(); + } + }); + + // Ensure that button clicks are actually handled. + return this.on('mousedown', ({target}) => { + if ($(target).hasClass('btn')) { return false; } + }); + } + + /** + * @inheritdoc + */ + viewForItem(item) { + const classes = ['list-item']; + + if (item.className) { + classes.push(item.className); + } + + if (item.isSelected) { + classes.push('active'); + } + + const className = classes.join(' '); + const displayText = item.name; + + return `\ +
  • ${displayText}
  • \ +`; + } + + /** + * @inheritdoc + */ + getFilterKey() { + return 'name'; + } + + /** + * Retrieves the text to display on the cancel button. + * + * @return {string} + */ + getCancelButtonText() { + return 'Cancel'; + } + + /** + * Retrieves the text to display on the confirmation button. + * + * @return {string} + */ + getConfirmButtonText() { + return 'Generate'; + } + + /** + * Retrieves the message that is displayed when there are no results. + * + * @return {string} + */ + getEmptyMessage() { + if (this.emptyMessage != null) { + return this.emptyMessage; + } + + return super.getEmptyMessage(); + } + + /** + * Sets the message that is displayed when there are no results. + * + * @param {string} emptyMessage + */ + setEmptyMessage(emptyMessage) { + return this.emptyMessage = emptyMessage; + } + + /** + * Retrieves the metadata to pass to the callbacks. + * + * @return {Object|null} + */ + getMetadata() { + return this.metadata; + } + + /** + * Sets the metadata to pass to the callbacks. + * + * @param {Object|null} metadata + */ + setMetadata(metadata) { + return this.metadata = metadata; + } + + /** + * @inheritdoc + */ + setItems(items) { + let i = 0; + + for (let item of Array.from(items)) { + item.index = i++; + } + + super.setItems(items); + + return this.selectedItems = []; + } + + /** + * @inheritdoc + */ + confirmed(item) { + let index; + item.isSelected = !item.isSelected; + + if (item.isSelected) { + this.selectedItems.push(item); + + } else { + index = this.selectedItems.indexOf(item); + + if (index >= 0) { + this.selectedItems.splice(index, 1); + } + } + + const selectedItem = this.getSelectedItem(); + index = selectedItem ? selectedItem.index : 0; + + this.populateList(); + + return this.selectItemView(this.list.find(`li:nth(${index})`)); + } + + /** + * Invoked when the user confirms his selections by pressing the confirmation button. + */ + confirmedByButton() { + this.invokeOnDidConfirm(); + this.restoreFocus(); + return this.panel.hide(); + } + + /** + * Invokes the on did confirm handler with the correct arguments (if it is set). + */ + invokeOnDidConfirm() { + if (this.onDidConfirm) { + return this.onDidConfirm(this.selectedItems, this.getMetadata()); + } + } + + /** + * @inheritdoc + */ + cancelled() { + if (this.onDidCancel) { + this.onDidCancel(this.getMetadata()); + } + + this.restoreFocus(); + return this.panel.hide(); + } + + /** + * Presents the view to the user. + */ + present() { + this.panel.show(); + return this.focusFilterEditor(); + } + }; + MultiSelectionView.initClass(); + return MultiSelectionView; +})()); diff --git a/lib/Refactoring/Utility/TypeHelper.coffee b/lib/Refactoring/Utility/TypeHelper.coffee deleted file mode 100644 index 847136f0..00000000 --- a/lib/Refactoring/Utility/TypeHelper.coffee +++ /dev/null @@ -1,195 +0,0 @@ -module.exports = - -class TypeHelper - ###* - * @var {Object|null} service - ### - service: null - - ###* - * @param {Object} service - ### - setService: (@service) -> - - ###* - * @return {Number} - ### - getCurrentProjectPhpVersion: () -> - projectSettings = @service.getCurrentProjectSettings() - - if projectSettings? - return projectSettings.phpVersion - - return 5.2 # Assume lowest supported version - - ###* - * @param {String|null} typeSpecification - * - * @return {Object|null} - ### - getReturnTypeHintForTypeSpecification: (typeSpecification) -> - return null if @getCurrentProjectPhpVersion() < 7.0 - - returnTypeHint = @getTypeHintForTypeSpecification(typeSpecification) - - if not returnTypeHint? or returnTypeHint.shouldSetDefaultValueToNull - return null - - return returnTypeHint.typeHint - - ###* - * @param {String|null} typeSpecification - * - * @return {Object|null} - ### - getTypeHintForTypeSpecification: (typeSpecification) -> - types = @getDocblockTypesFromDocblockTypeSpecification(typeSpecification) - - return @getTypeHintForDocblockTypes(types) - - ###* - * @param {String|null} typeSpecification - * - * @return {Array} - ### - getDocblockTypesFromDocblockTypeSpecification: (typeSpecification) -> - return [] if not typeSpecification? - return typeSpecification.split('|') - - ###* - * @param {Array} types - * @param {boolean} allowPhp7 - * - * @return {Object|null} - ### - getTypeHintForDocblockTypes: (types) -> - isNullable = false - - types = types.filter (type) => - if type == 'null' - isNullable = true - - return type != 'null' - - typeHint = null - previousTypeHint = null - - for type in types - typeHint = @getTypeHintForDocblockType(type) - - if previousTypeHint? and typeHint != previousTypeHint - # Several different type hints are necessary, we can't provide a common denominator. - return null - - previousTypeHint = typeHint - - data = { - typeHint : typeHint - shouldSetDefaultValueToNull : false - } - - return data if not typeHint? - return data if not isNullable - - currentPhpVersion = @getCurrentProjectPhpVersion() - - if currentPhpVersion >= 7.1 - data.typeHint = '?' + typeHint - data.shouldSetDefaultValueToNull = false - - else - data.shouldSetDefaultValueToNull = true - - return data - - ###* - * @param {String|null} type - * - * @return {String|null} - ### - getTypeHintForDocblockType: (type) -> - return null if not type? - return type if @isClassType(type) - return @getScalarTypeHintForDocblockType(type) - - ###* - * @param {String|null} type - * - * @return {boolean} - ### - isClassType: (type) -> - return if (@getScalarTypeHintForDocblockType(type) == false) then true else false - - ###* - * @param {String|null} type - * - * @return {String|null|false} Null if the type is recognized, but there is no type hint available, false of the - * type is not recognized at all, and the type hint itself if it is recognized and there - * is a type hint. - ### - getScalarTypeHintForDocblockType: (type) -> - return null if not type? - - phpVersion = @getCurrentProjectPhpVersion() - - if phpVersion >= 7.1 - return 'iterable' if type == 'iterable' - return 'void' if type == 'void' - - else if phpVersion >= 7.0 - return 'string' if type == 'string' - return 'int' if type == 'int' - return 'bool' if type == 'bool' - return 'float' if type == 'float' - return 'resource' if type == 'resource' - return 'bool' if type == 'false' - return 'bool' if type == 'true' - - else - return null if type == 'string' - return null if type == 'int' - return null if type == 'bool' - return null if type == 'float' - return null if type == 'resource' - return null if type == 'false' - return null if type == 'true' - - return 'array' if type == 'array' - return 'callable' if type == 'callable' - return 'self' if type == 'self' - return 'self' if type == 'static' - return 'array' if /^.+\[\]$/.test(type) - - return null if type == 'object' - return null if type == 'mixed' - return null if type == 'void' - return null if type == 'null' - return null if type == 'parent' - return null if type == '$this' - - return false - - ###* - * Takes a type list (list of type objects) and turns them into a single docblock type specification. - * - * @param {Array} typeList - * - * @return {String} - ### - buildTypeSpecificationFromTypeArray: (typeList) -> - typeNames = typeList.map (type) -> - return type.type - - return @buildTypeSpecificationFromTypes(typeNames) - - ###* - * Takes a list of type names and turns them into a single docblock type specification. - * - * @param {Array} typeNames - * - * @return {String} - ### - buildTypeSpecificationFromTypes: (typeNames) -> - return 'mixed' if typeNames.length == 0 - - return typeNames.join('|') diff --git a/lib/Refactoring/Utility/TypeHelper.js b/lib/Refactoring/Utility/TypeHelper.js new file mode 100644 index 00000000..cff9dc1b --- /dev/null +++ b/lib/Refactoring/Utility/TypeHelper.js @@ -0,0 +1,230 @@ +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS206: Consider reworking classes to avoid initClass + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let TypeHelper; +module.exports = + +(TypeHelper = (function() { + TypeHelper = class TypeHelper { + static initClass() { + /** + * @var {Object|null} service + */ + this.prototype.service = null; + } + + /** + * @param {Object} service + */ + setService(service) { + this.service = service; + } + + /** + * @return {Number} + */ + getCurrentProjectPhpVersion() { + const projectSettings = this.service.getCurrentProjectSettings(); + + if (projectSettings != null) { + return projectSettings.phpVersion; + } + + return 5.2; // Assume lowest supported version + } + + /** + * @param {String|null} typeSpecification + * + * @return {Object|null} + */ + getReturnTypeHintForTypeSpecification(typeSpecification) { + if (this.getCurrentProjectPhpVersion() < 7.0) { return null; } + + const returnTypeHint = this.getTypeHintForTypeSpecification(typeSpecification); + + if ((returnTypeHint == null) || returnTypeHint.shouldSetDefaultValueToNull) { + return null; + } + + return returnTypeHint.typeHint; + } + + /** + * @param {String|null} typeSpecification + * + * @return {Object|null} + */ + getTypeHintForTypeSpecification(typeSpecification) { + const types = this.getDocblockTypesFromDocblockTypeSpecification(typeSpecification); + + return this.getTypeHintForDocblockTypes(types); + } + + /** + * @param {String|null} typeSpecification + * + * @return {Array} + */ + getDocblockTypesFromDocblockTypeSpecification(typeSpecification) { + if ((typeSpecification == null)) { return []; } + return typeSpecification.split('|'); + } + + /** + * @param {Array} types + * @param {boolean} allowPhp7 + * + * @return {Object|null} + */ + getTypeHintForDocblockTypes(types) { + let isNullable = false; + + types = types.filter(type => { + if (type === 'null') { + isNullable = true; + } + + return type !== 'null'; + }); + + let typeHint = null; + let previousTypeHint = null; + + for (let type of Array.from(types)) { + typeHint = this.getTypeHintForDocblockType(type); + + if ((previousTypeHint != null) && (typeHint !== previousTypeHint)) { + // Several different type hints are necessary, we can't provide a common denominator. + return null; + } + + previousTypeHint = typeHint; + } + + const data = { + typeHint, + shouldSetDefaultValueToNull : false + }; + + if ((typeHint == null)) { return data; } + if (!isNullable) { return data; } + + const currentPhpVersion = this.getCurrentProjectPhpVersion(); + + if (currentPhpVersion >= 7.1) { + data.typeHint = `?${typeHint}`; + data.shouldSetDefaultValueToNull = false; + + } else { + data.shouldSetDefaultValueToNull = true; + } + + return data; + } + + /** + * @param {String|null} type + * + * @return {String|null} + */ + getTypeHintForDocblockType(type) { + if ((type == null)) { return null; } + if (this.isClassType(type)) { return type; } + return this.getScalarTypeHintForDocblockType(type); + } + + /** + * @param {String|null} type + * + * @return {boolean} + */ + isClassType(type) { + if (this.getScalarTypeHintForDocblockType(type) === false) { return true; } else { return false; } + } + + /** + * @param {String|null} type + * + * @return {String|null|false} Null if the type is recognized, but there is no type hint available, false of the + * type is not recognized at all, and the type hint itself if it is recognized and there + * is a type hint. + */ + getScalarTypeHintForDocblockType(type) { + if ((type == null)) { return null; } + + const phpVersion = this.getCurrentProjectPhpVersion(); + + if (phpVersion >= 7.1) { + if (type === 'iterable') { return 'iterable'; } + if (type === 'void') { return 'void'; } + + } else if (phpVersion >= 7.0) { + if (type === 'string') { return 'string'; } + if (type === 'int') { return 'int'; } + if (type === 'bool') { return 'bool'; } + if (type === 'float') { return 'float'; } + if (type === 'resource') { return 'resource'; } + if (type === 'false') { return 'bool'; } + if (type === 'true') { return 'bool'; } + + } else { + if (type === 'string') { return null; } + if (type === 'int') { return null; } + if (type === 'bool') { return null; } + if (type === 'float') { return null; } + if (type === 'resource') { return null; } + if (type === 'false') { return null; } + if (type === 'true') { return null; } + } + + if (type === 'array') { return 'array'; } + if (type === 'callable') { return 'callable'; } + if (type === 'self') { return 'self'; } + if (type === 'static') { return 'self'; } + if (/^.+\[\]$/.test(type)) { return 'array'; } + + if (type === 'object') { return null; } + if (type === 'mixed') { return null; } + if (type === 'void') { return null; } + if (type === 'null') { return null; } + if (type === 'parent') { return null; } + if (type === '$this') { return null; } + + return false; + } + + /** + * Takes a type list (list of type objects) and turns them into a single docblock type specification. + * + * @param {Array} typeList + * + * @return {String} + */ + buildTypeSpecificationFromTypeArray(typeList) { + const typeNames = typeList.map(type => type.type); + + return this.buildTypeSpecificationFromTypes(typeNames); + } + + /** + * Takes a list of type names and turns them into a single docblock type specification. + * + * @param {Array} typeNames + * + * @return {String} + */ + buildTypeSpecificationFromTypes(typeNames) { + if (typeNames.length === 0) { return 'mixed'; } + + return typeNames.join('|'); + } + }; + TypeHelper.initClass(); + return TypeHelper; +})()); diff --git a/lib/Service.coffee b/lib/Service.coffee deleted file mode 100644 index fb9983c9..00000000 --- a/lib/Service.coffee +++ /dev/null @@ -1,605 +0,0 @@ -{Buffer} = require 'buffer' - -Popover = require './Widgets/Popover' -AttachedPopover = require './Widgets/AttachedPopover' - -module.exports = - -##* -# The service that is exposed to other packages. -## -class Service - ###* - * @var {Object} - ### - config: null - - ###* - * @var {Object} - ### - proxy: null - - ###* - * @var {Object} - ### - projectManager: null - - ###* - * @var {Object} - ### - indexingMediator: null - - ###* - * @var {Object} - ### - useStatementHelper: null - - ###* - * Constructor. - * - * @param {AtomConfig} config - * @param {CachingProxy} proxy - * @param {Object} projectManager - * @param {Object} indexingMediator - * @param {Object} useStatementHelper - ### - constructor: (@config, @proxy, @projectManager, @indexingMediator, @useStatementHelper) -> - - ###* - * Retrieves the use statement helper, which contains utility methods for dealing with use statements. - * - * @return {Object} - ### - getUseStatementHelper: () -> - return @useStatementHelper - - ###* - * Retrieves the settings (that are specific to this package) for the currently active project. If there is no - * active project or the project does not have any settings, null is returned. - * - * @return {Object|null} - ### - getCurrentProjectSettings: () -> - return @projectManager.getCurrentProjectSettings() - - ###* - * Retrieves a list of available classes. - * - * @return {Promise} - ### - getClassList: () -> - return @proxy.getClassList() - - ###* - * Retrieves a list of available classes in the specified file. - * - * @param {String} file - * - * @return {Promise} - ### - getClassListForFile: (file) -> - return @proxy.getClassListForFile(file) - - ###* - * Retrieves a list of namespaces. - * - * @return {Promise} - ### - getNamespaceList: () -> - return @proxy.getNamespaceList() - - ###* - * Retrieves a list of namespaces in the specified file. - * - * @param {String} file - * - * @return {Promise} - ### - getNamespaceListForFile: (file) -> - return @proxy.getNamespaceListForFile(file) - - ###* - * Retrieves a list of available global constants. - * - * @return {Promise} - ### - getGlobalConstants: () -> - return @proxy.getGlobalConstants() - - ###* - * Retrieves a list of available global functions. - * - * @return {Promise} - ### - getGlobalFunctions: () -> - return @proxy.getGlobalFunctions() - - ###* - * Retrieves a list of available members of the class (or interface, trait, ...) with the specified name. - * - * @param {String} className - * - * @return {Promise} - ### - getClassInfo: (className) -> - return @proxy.getClassInfo(className) - - ###* - * Resolves a local type in the specified file, based on use statements and the namespace. - * - * @param {String} file - * @param {Number} line The line the type is located at. The first line is 1, not 0. - * @param {String} type - * @param {String} kind The kind of element. Either 'classlike', 'constant' or 'function'. - * - * @return {Promise} - ### - resolveType: (file, line, type, kind) -> - return @proxy.resolveType(file, line, type, kind) - - ###* - * Localizes a type to the specified file, making it relative to local use statements, if possible. If not possible, - * null is returned. - * - * @param {String} file - * @param {Number} line The line the type is located at. The first line is 1, not 0. - * @param {String} type - * @param {String} kind The kind of element. Either 'classlike', 'constant' or 'function'. - * - * @return {Promise} - ### - localizeType: (file, line, type, kind) -> - return @proxy.localizeType(file, line, type, kind) - - ###* - * Lints the specified file. - * - * @param {String} file - * @param {String|null} source The source code of the file to index. May be null if a directory is passed instead. - * @param {Object} options Additional options to set. Boolean properties noUnknownClasses, noUnknownMembers, - * noUnknownGlobalFunctions, noUnknownGlobalConstants, noDocblockCorrectness, - * noUnusedUseStatements and noMissingDocumentation are supported. - * - * @return {CancellablePromise} - ### - lint: (file, source, options = {}) -> - return @proxy.lint(file, source, options) - - ###* - * Fetches all available variables at a specific location. - * - * @param {String} file The path to the file to examine. May be null if the source parameter is passed. - * @param {String|null} source The source code to search. May be null if a file is passed instead. - * @param {Number} offset The character offset into the file to examine. - * - * @return {Promise} - ### - getAvailableVariablesByOffset: (file, source, offset) -> - return @proxy.getAvailableVariables(file, source, offset) - - ###* - * Deduces the resulting types of an expression. - * - * @param {String|null} expression The expression to deduce the type of, e.g. '$this->foo()'. If null, the - * expression just before the specified offset will be used. - * @param {String} file The path to the file to examine. - * @param {String|null} source The source code to search. May be null if a file is passed instead. - * @param {Number} offset The character offset into the file to examine. - * @param {bool} ignoreLastElement Whether to remove the last element or not, this is useful when the user - * is still writing code, e.g. "$this->foo()->b" would normally return the - * type (class) of 'b', as it is the last element, but as the user is still - * writing code, you may instead be interested in the type of 'foo()' - * instead. - * - * @return {Promise} - ### - deduceTypes: (expression, file, source, offset, ignoreLastElement) -> - return @proxy.deduceTypes(expression, file, source, offset, ignoreLastElement) - - ###* - * Retrieves autocompletion suggestions for a specific location. - * - * @param {Number} offset The character offset into the file to examine. - * @param {String} file The path to the file to examine. - * @param {String|null} source The source code to search. May be null if a file is passed instead. - * - * @return {CancellablePromise} - ### - autocomplete: (offset, file, source) -> - return @proxy.autocomplete(offset, file, source) - - ###* - * Fetches the contents of the tooltip to display at the specified offset. - * - * @param {String} file The path to the file to examine. - * @param {String|null} source The source code to search. May be null if a file is passed instead. - * @param {Number} offset The character offset into the file to examine. - * - * @return {CancellablePromise} - ### - tooltip: (file, source, offset) -> - return @proxy.tooltip(file, source, offset) - - ###* - * Fetches signature help for a method or function call. - * - * @param {String} file The path to the file to examine. - * @param {String|null} source The source code to search. May be null if a file is passed instead. - * @param {Number} offset The character offset into the file to examine. - * - * @return {CancellablePromise} - ### - signatureHelp: (file, source, offset) -> - return @proxy.signatureHelp(file, source, offset) - - ###* - * Fetches definition information for code navigation purposes of the structural element at the specified location. - * - * @param {String} file The path to the file to examine. - * @param {String|null} source The source code to search. May be null if a file is passed instead. - * @param {Number} offset The character offset into the file to examine. - * - * @return {CancellablePromise} - ### - gotoDefinition: (file, source, offset) -> - return @proxy.gotoDefinition(file, source, offset) - - ###* - * Convenience alias for {@see deduceTypes}. - * - * @param {String} expression - * @param {TextEditor} editor - * @param {Range} bufferPosition - * - * @return {Promise} - ### - deduceTypesAt: (expression, editor, bufferPosition) -> - offset = editor.getBuffer().characterIndexForPosition(bufferPosition) - - bufferText = editor.getBuffer().getText() - - return @deduceTypes(expression, editor.getPath(), bufferText, offset) - - ###* - * Convenience alias for {@see autocomplete}. - * - * @param {TextEditor} editor - * @param {Range} bufferPosition - * - * @return {CancellablePromise} - ### - autocompleteAt: (editor, bufferPosition) -> - offset = editor.getBuffer().characterIndexForPosition(bufferPosition) - - bufferText = editor.getBuffer().getText() - - return @autocomplete(offset, editor.getPath(), bufferText) - - ###* - * Convenience alias for {@see tooltip}. - * - * @param {TextEditor} editor - * @param {Range} bufferPosition - * - * @return {Promise} - ### - tooltipAt: (editor, bufferPosition) -> - offset = editor.getBuffer().characterIndexForPosition(bufferPosition) - - bufferText = editor.getBuffer().getText() - - return @tooltip(editor.getPath(), bufferText, offset) - - ###* - * Convenience alias for {@see signatureHelp}. - * - * @param {TextEditor} editor - * @param {Range} bufferPosition - * - * @return {CancellablePromise} - ### - signatureHelpAt: (editor, bufferPosition) -> - offset = editor.getBuffer().characterIndexForPosition(bufferPosition) - - bufferText = editor.getBuffer().getText() - - return @signatureHelp(editor.getPath(), bufferText, offset) - - ###* - * Convenience alias for {@see gotoDefinition}. - * - * @param {TextEditor} editor - * @param {Range} bufferPosition - * - * @return {Promise} - ### - gotoDefinitionAt: (editor, bufferPosition) -> - offset = editor.getBuffer().characterIndexForPosition(bufferPosition) - - bufferText = editor.getBuffer().getText() - - return @gotoDefinition(editor.getPath(), bufferText, offset) - - ###* - * Refreshes the specified file or folder. This method is asynchronous and will return immediately. - * - * @param {String|Array} path The full path to the file or folder to refresh. Alternatively, - * this can be a list of items to index at the same time. - * @param {String|null} source The source code of the file to index. May be null if a directory is - * passed instead. - * @param {Array} excludedPaths A list of paths to exclude from indexing. - * @param {Array} fileExtensionsToIndex A list of file extensions (without leading dot) to index. - * - * @return {Promise} - ### - reindex: (path, source, excludedPaths, fileExtensionsToIndex) -> - return @indexingMediator.reindex(path, source, excludedPaths, fileExtensionsToIndex) - - ###* - * Initializes a project. - * - * @return {Promise} - ### - initialize: () -> - return @indexingMediator.initialize() - - ###* - * Vacuums a project, cleaning up the index database (e.g. pruning files that no longer exist). - * - * @return {Promise} - ### - vacuum: () -> - return @indexingMediator.vacuum() - - ###* - * Attaches a callback to indexing started event. The returned disposable can be used to detach your event handler. - * - * @param {Callback} callback A callback that takes one parameter which contains a 'path' property. - * - * @return {Disposable} - ### - onDidStartIndexing: (callback) -> - return @indexingMediator.onDidStartIndexing(callback) - - ###* - * Attaches a callback to indexing progress event. The returned disposable can be used to detach your event handler. - * - * @param {Callback} callback A callback that takes one parameter which contains a 'path' and a 'percentage' - * property. - * - * @return {Disposable} - ### - onDidIndexingProgress: (callback) -> - return @indexingMediator.onDidIndexingProgress(callback) - - ###* - * Attaches a callback to indexing finished event. The returned disposable can be used to detach your event handler. - * - * @param {Callback} callback A callback that takes one parameter which contains an 'output' and a 'path' property. - * - * @return {Disposable} - ### - onDidFinishIndexing: (callback) -> - return @indexingMediator.onDidFinishIndexing(callback) - - ###* - * Attaches a callback to indexing failed event. The returned disposable can be used to detach your event handler. - * - * @param {Callback} callback A callback that takes one parameter which contains an 'error' and a 'path' property. - * - * @return {Disposable} - ### - onDidFailIndexing: (callback) -> - return @indexingMediator.onDidFailIndexing(callback) - - ###* - * Determines the current class' FQCN based on the specified buffer position. - * - * @param {TextEditor} editor The editor that contains the class (needed to resolve relative class names). - * @param {Point} bufferPosition - * - * @return {Promise} - ### - determineCurrentClassName: (editor, bufferPosition) -> - return new Promise (resolve, reject) => - path = editor.getPath() - - if not path? - reject() - return - - successHandler = (classesInFile) => - for name,classInfo of classesInFile - if bufferPosition.row >= classInfo.startLine and bufferPosition.row <= classInfo.endLine - resolve(name) - - resolve(null) - - failureHandler = () => - reject() - - return @getClassListForFile(path).then(successHandler, failureHandler) - - ###* - * Determines the current namespace on the specified buffer position. - * - * @param {TextEditor} editor The editor that contains the class (needed to resolve relative class names). - * @param {Point} bufferPosition - * - * @return {Promise} - ### - determineCurrentNamespaceName: (editor, bufferPosition) -> - return new Promise (resolve, reject) => - path = editor.getPath() - - if not path? - reject() - return - - successHandler = (namespacesInFile) => - for id,namespace of namespacesInFile - if bufferPosition.row >= namespace.startLine and bufferPosition.row <= namespace.endLine - resolve(namespace.name) - return - - resolve(null) - - failureHandler = () => - reject() - - return @getNamespaceListForFile(path).then(successHandler, failureHandler) - - ###* - * Convenience function that resolves types using {@see resolveType}, automatically determining the correct - * parameters for the editor and buffer position. - * - * @param {TextEditor} editor The editor. - * @param {Point} bufferPosition The location of the type. - * @param {String} type The (local) type to resolve. - * @param {String} kind The kind of element. Either 'classlike', 'constant' or 'function'. - * - * @return {Promise} - * - * @example In a file with namespace A\B, determining C could lead to A\B\C. - ### - resolveTypeAt: (editor, bufferPosition, type, kind) -> - return @resolveType(editor.getPath(), bufferPosition.row + 1, type, kind) - - ###* - * Retrieves all variables that are available at the specified buffer position. - * - * @param {TextEditor} editor - * @param {Range} bufferPosition - * - * @return {Promise} - ### - getAvailableVariables: (editor, bufferPosition) -> - offset = editor.getBuffer().characterIndexForPosition(bufferPosition) - - return @getAvailableVariablesByOffset(editor.getPath(), editor.getBuffer().getText(), offset) - - ###* - * Retrieves the types that are being used (called) at the specified location in the buffer. Note that this does not - * guarantee that the returned types actually exist. You can use {@see getClassInfo} on the returned class name - * to check for this instead. - * - * @param {TextEditor} editor The text editor to use. - * @param {Point} bufferPosition The cursor location of the item, such as the class member. Note that this - * should always be at the end of the actual member (i.e. just after it). - * If you want to ignore the element at the buffer position itself, see - * 'ignoreLastElement'. - * @param {boolean} ignoreLastElement Whether to remove the last element or not, this is useful when the user - * is still writing code, e.g. "$this->foo()->b" would normally return the - * type (class) of 'b', as it is the last element, but as the user is still - * writing code, you may instead be interested in the type of 'foo()' instead. - * - * @return {Promise} - * - * @example Invoking it on MyMethod::foo()->bar() will ask what class 'bar' is invoked on, which will whatever types - * foo returns. - ### - getResultingTypesAt: (editor, bufferPosition, ignoreLastElement) -> - offset = editor.getBuffer().characterIndexForPosition(bufferPosition) - - bufferText = editor.getBuffer().getText() - - return @deduceTypes(null, editor.getPath(), bufferText, offset, true) - - ###* - * Retrieves the call stack of the function or method that is being invoked at the specified position. This can be - * used to fetch information about the function or method call the cursor is in. - * - * @param {TextEditor} editor - * @param {Point} bufferPosition - * - * @return {Promise} With elements 'callStack' (array) as well as 'argumentIndex' which denotes the argument in the - * parameter list the position is located at. Returns 'null' if not in a method or function call. - * - * @example "$this->test(1, function () {},| 2);" (where the vertical bar denotes the cursor position) will yield - * ['$this', 'test']. - ### - getInvocationInfoAt: (editor, bufferPosition) -> - offset = editor.getBuffer().characterIndexForPosition(bufferPosition) - - bufferText = editor.getBuffer().getText() - - return @getInvocationInfo(editor.getPath(), bufferText, offset) - - ###* - * Creates a popover with the specified constructor arguments. - ### - createPopover: () -> - return new Popover(arguments...) - - ###* - * Creates an attached popover with the specified constructor arguments. - ### - createAttachedPopover: () -> - return new AttachedPopover(arguments...) - - ###* - * Indicates if the specified type is a basic type (e.g. int, array, object, etc.). - * - * @param {String} type - * - * @return {boolean} - ### - isBasicType: (type) -> - return /^(string|int|bool|float|object|mixed|array|resource|void|null|callable|false|true|self|static|parent|\$this)$/i.test(type) - - ###* - * Utility function to convert byte offsets returned by the service into character offsets. - * - * @param {Number} byteOffset - * @param {String} string - * - * @return {Number} - ### - getCharacterOffsetFromByteOffset: (byteOffset, string) -> - buffer = new Buffer(string) - - return buffer.slice(0, byteOffset).toString().length - - ###* - * @param {String} fqcn - * - * @return {String} - ### - getDocumentationUrlForClass: (fqcn) -> - return @config.get('php_documentation_base_urls').classes + @getNormalizedFqcnDocumentationUrl(fqcn) - - ###* - * @param {String} fqcn - * - * @return {String} - ### - getDocumentationUrlForFunction: (fqcn) -> - return @config.get('php_documentation_base_urls').functions + @getNormalizedFqcnDocumentationUrl(fqcn) - - ###* - * @param {String} classlikeFqcn - * @param {String} name - * - * @return {String} - ### - getDocumentationUrlForClassMethod: (classlikeFqcn, name) -> - return @config.get('php_documentation_base_urls').root + @getNormalizedFqcnDocumentationUrl(classlikeFqcn) + '.' + @getNormalizeMethodDocumentationUrl(name) - - ###* - * @param {String} name - * - * @return {String} - ### - getNormalizedFqcnDocumentationUrl: (name) -> - if name.length > 0 and name[0] == '\\' - name = name.substr(1) - - return name.replace(/\\/g, '-').toLowerCase() - - ###* - * @param {String} name - * - * @return {String} - ### - getNormalizeMethodDocumentationUrl: (name) -> - return name.replace(/^__/, '') diff --git a/lib/Service.js b/lib/Service.js new file mode 100644 index 00000000..3274066a --- /dev/null +++ b/lib/Service.js @@ -0,0 +1,687 @@ +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * DS206: Consider reworking classes to avoid initClass + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let Service; +const {Buffer} = require('buffer'); + +const Popover = require('./Widgets/Popover'); +const AttachedPopover = require('./Widgets/AttachedPopover'); + +module.exports = + +//#* +// The service that is exposed to other packages. +//# +(Service = (function() { + Service = class Service { + static initClass() { + /** + * @var {Object} + */ + this.prototype.config = null; + + /** + * @var {Object} + */ + this.prototype.proxy = null; + + /** + * @var {Object} + */ + this.prototype.projectManager = null; + + /** + * @var {Object} + */ + this.prototype.indexingMediator = null; + + /** + * @var {Object} + */ + this.prototype.useStatementHelper = null; + } + + /** + * Constructor. + * + * @param {AtomConfig} config + * @param {CachingProxy} proxy + * @param {Object} projectManager + * @param {Object} indexingMediator + * @param {Object} useStatementHelper + */ + constructor(config, proxy, projectManager, indexingMediator, useStatementHelper) { + this.config = config; + this.proxy = proxy; + this.projectManager = projectManager; + this.indexingMediator = indexingMediator; + this.useStatementHelper = useStatementHelper; + } + + /** + * Retrieves the use statement helper, which contains utility methods for dealing with use statements. + * + * @return {Object} + */ + getUseStatementHelper() { + return this.useStatementHelper; + } + + /** + * Retrieves the settings (that are specific to this package) for the currently active project. If there is no + * active project or the project does not have any settings, null is returned. + * + * @return {Object|null} + */ + getCurrentProjectSettings() { + return this.projectManager.getCurrentProjectSettings(); + } + + /** + * Retrieves a list of available classes. + * + * @return {Promise} + */ + getClassList() { + return this.proxy.getClassList(); + } + + /** + * Retrieves a list of available classes in the specified file. + * + * @param {String} file + * + * @return {Promise} + */ + getClassListForFile(file) { + return this.proxy.getClassListForFile(file); + } + + /** + * Retrieves a list of namespaces. + * + * @return {Promise} + */ + getNamespaceList() { + return this.proxy.getNamespaceList(); + } + + /** + * Retrieves a list of namespaces in the specified file. + * + * @param {String} file + * + * @return {Promise} + */ + getNamespaceListForFile(file) { + return this.proxy.getNamespaceListForFile(file); + } + + /** + * Retrieves a list of available global constants. + * + * @return {Promise} + */ + getGlobalConstants() { + return this.proxy.getGlobalConstants(); + } + + /** + * Retrieves a list of available global functions. + * + * @return {Promise} + */ + getGlobalFunctions() { + return this.proxy.getGlobalFunctions(); + } + + /** + * Retrieves a list of available members of the class (or interface, trait, ...) with the specified name. + * + * @param {String} className + * + * @return {Promise} + */ + getClassInfo(className) { + return this.proxy.getClassInfo(className); + } + + /** + * Resolves a local type in the specified file, based on use statements and the namespace. + * + * @param {String} file + * @param {Number} line The line the type is located at. The first line is 1, not 0. + * @param {String} type + * @param {String} kind The kind of element. Either 'classlike', 'constant' or 'function'. + * + * @return {Promise} + */ + resolveType(file, line, type, kind) { + return this.proxy.resolveType(file, line, type, kind); + } + + /** + * Localizes a type to the specified file, making it relative to local use statements, if possible. If not possible, + * null is returned. + * + * @param {String} file + * @param {Number} line The line the type is located at. The first line is 1, not 0. + * @param {String} type + * @param {String} kind The kind of element. Either 'classlike', 'constant' or 'function'. + * + * @return {Promise} + */ + localizeType(file, line, type, kind) { + return this.proxy.localizeType(file, line, type, kind); + } + + /** + * Lints the specified file. + * + * @param {String} file + * @param {String|null} source The source code of the file to index. May be null if a directory is passed instead. + * @param {Object} options Additional options to set. Boolean properties noUnknownClasses, noUnknownMembers, + * noUnknownGlobalFunctions, noUnknownGlobalConstants, noDocblockCorrectness, + * noUnusedUseStatements and noMissingDocumentation are supported. + * + * @return {CancellablePromise} + */ + lint(file, source, options) { + if (options == null) { options = {}; } + return this.proxy.lint(file, source, options); + } + + /** + * Fetches all available variables at a specific location. + * + * @param {String} file The path to the file to examine. May be null if the source parameter is passed. + * @param {String|null} source The source code to search. May be null if a file is passed instead. + * @param {Number} offset The character offset into the file to examine. + * + * @return {Promise} + */ + getAvailableVariablesByOffset(file, source, offset) { + return this.proxy.getAvailableVariables(file, source, offset); + } + + /** + * Deduces the resulting types of an expression. + * + * @param {String|null} expression The expression to deduce the type of, e.g. '$this->foo()'. If null, the + * expression just before the specified offset will be used. + * @param {String} file The path to the file to examine. + * @param {String|null} source The source code to search. May be null if a file is passed instead. + * @param {Number} offset The character offset into the file to examine. + * @param {bool} ignoreLastElement Whether to remove the last element or not, this is useful when the user + * is still writing code, e.g. "$this->foo()->b" would normally return the + * type (class) of 'b', as it is the last element, but as the user is still + * writing code, you may instead be interested in the type of 'foo()' + * instead. + * + * @return {Promise} + */ + deduceTypes(expression, file, source, offset, ignoreLastElement) { + return this.proxy.deduceTypes(expression, file, source, offset, ignoreLastElement); + } + + /** + * Retrieves autocompletion suggestions for a specific location. + * + * @param {Number} offset The character offset into the file to examine. + * @param {String} file The path to the file to examine. + * @param {String|null} source The source code to search. May be null if a file is passed instead. + * + * @return {CancellablePromise} + */ + autocomplete(offset, file, source) { + return this.proxy.autocomplete(offset, file, source); + } + + /** + * Fetches the contents of the tooltip to display at the specified offset. + * + * @param {String} file The path to the file to examine. + * @param {String|null} source The source code to search. May be null if a file is passed instead. + * @param {Number} offset The character offset into the file to examine. + * + * @return {CancellablePromise} + */ + tooltip(file, source, offset) { + return this.proxy.tooltip(file, source, offset); + } + + /** + * Fetches signature help for a method or function call. + * + * @param {String} file The path to the file to examine. + * @param {String|null} source The source code to search. May be null if a file is passed instead. + * @param {Number} offset The character offset into the file to examine. + * + * @return {CancellablePromise} + */ + signatureHelp(file, source, offset) { + return this.proxy.signatureHelp(file, source, offset); + } + + /** + * Fetches definition information for code navigation purposes of the structural element at the specified location. + * + * @param {String} file The path to the file to examine. + * @param {String|null} source The source code to search. May be null if a file is passed instead. + * @param {Number} offset The character offset into the file to examine. + * + * @return {CancellablePromise} + */ + gotoDefinition(file, source, offset) { + return this.proxy.gotoDefinition(file, source, offset); + } + + /** + * Convenience alias for {@see deduceTypes}. + * + * @param {String} expression + * @param {TextEditor} editor + * @param {Range} bufferPosition + * + * @return {Promise} + */ + deduceTypesAt(expression, editor, bufferPosition) { + const offset = editor.getBuffer().characterIndexForPosition(bufferPosition); + + const bufferText = editor.getBuffer().getText(); + + return this.deduceTypes(expression, editor.getPath(), bufferText, offset); + } + + /** + * Convenience alias for {@see autocomplete}. + * + * @param {TextEditor} editor + * @param {Range} bufferPosition + * + * @return {CancellablePromise} + */ + autocompleteAt(editor, bufferPosition) { + const offset = editor.getBuffer().characterIndexForPosition(bufferPosition); + + const bufferText = editor.getBuffer().getText(); + + return this.autocomplete(offset, editor.getPath(), bufferText); + } + + /** + * Convenience alias for {@see tooltip}. + * + * @param {TextEditor} editor + * @param {Range} bufferPosition + * + * @return {Promise} + */ + tooltipAt(editor, bufferPosition) { + const offset = editor.getBuffer().characterIndexForPosition(bufferPosition); + + const bufferText = editor.getBuffer().getText(); + + return this.tooltip(editor.getPath(), bufferText, offset); + } + + /** + * Convenience alias for {@see signatureHelp}. + * + * @param {TextEditor} editor + * @param {Range} bufferPosition + * + * @return {CancellablePromise} + */ + signatureHelpAt(editor, bufferPosition) { + const offset = editor.getBuffer().characterIndexForPosition(bufferPosition); + + const bufferText = editor.getBuffer().getText(); + + return this.signatureHelp(editor.getPath(), bufferText, offset); + } + + /** + * Convenience alias for {@see gotoDefinition}. + * + * @param {TextEditor} editor + * @param {Range} bufferPosition + * + * @return {Promise} + */ + gotoDefinitionAt(editor, bufferPosition) { + const offset = editor.getBuffer().characterIndexForPosition(bufferPosition); + + const bufferText = editor.getBuffer().getText(); + + return this.gotoDefinition(editor.getPath(), bufferText, offset); + } + + /** + * Refreshes the specified file or folder. This method is asynchronous and will return immediately. + * + * @param {String|Array} path The full path to the file or folder to refresh. Alternatively, + * this can be a list of items to index at the same time. + * @param {String|null} source The source code of the file to index. May be null if a directory is + * passed instead. + * @param {Array} excludedPaths A list of paths to exclude from indexing. + * @param {Array} fileExtensionsToIndex A list of file extensions (without leading dot) to index. + * + * @return {Promise} + */ + reindex(path, source, excludedPaths, fileExtensionsToIndex) { + return this.indexingMediator.reindex(path, source, excludedPaths, fileExtensionsToIndex); + } + + /** + * Initializes a project. + * + * @return {Promise} + */ + initialize() { + return this.indexingMediator.initialize(); + } + + /** + * Vacuums a project, cleaning up the index database (e.g. pruning files that no longer exist). + * + * @return {Promise} + */ + vacuum() { + return this.indexingMediator.vacuum(); + } + + /** + * Attaches a callback to indexing started event. The returned disposable can be used to detach your event handler. + * + * @param {Callback} callback A callback that takes one parameter which contains a 'path' property. + * + * @return {Disposable} + */ + onDidStartIndexing(callback) { + return this.indexingMediator.onDidStartIndexing(callback); + } + + /** + * Attaches a callback to indexing progress event. The returned disposable can be used to detach your event handler. + * + * @param {Callback} callback A callback that takes one parameter which contains a 'path' and a 'percentage' + * property. + * + * @return {Disposable} + */ + onDidIndexingProgress(callback) { + return this.indexingMediator.onDidIndexingProgress(callback); + } + + /** + * Attaches a callback to indexing finished event. The returned disposable can be used to detach your event handler. + * + * @param {Callback} callback A callback that takes one parameter which contains an 'output' and a 'path' property. + * + * @return {Disposable} + */ + onDidFinishIndexing(callback) { + return this.indexingMediator.onDidFinishIndexing(callback); + } + + /** + * Attaches a callback to indexing failed event. The returned disposable can be used to detach your event handler. + * + * @param {Callback} callback A callback that takes one parameter which contains an 'error' and a 'path' property. + * + * @return {Disposable} + */ + onDidFailIndexing(callback) { + return this.indexingMediator.onDidFailIndexing(callback); + } + + /** + * Determines the current class' FQCN based on the specified buffer position. + * + * @param {TextEditor} editor The editor that contains the class (needed to resolve relative class names). + * @param {Point} bufferPosition + * + * @return {Promise} + */ + determineCurrentClassName(editor, bufferPosition) { + return new Promise((resolve, reject) => { + const path = editor.getPath(); + + if ((path == null)) { + reject(); + return; + } + + const successHandler = classesInFile => { + for (let name in classesInFile) { + const classInfo = classesInFile[name]; + if ((bufferPosition.row >= classInfo.startLine) && (bufferPosition.row <= classInfo.endLine)) { + resolve(name); + } + } + + return resolve(null); + }; + + const failureHandler = () => { + return reject(); + }; + + return this.getClassListForFile(path).then(successHandler, failureHandler); + }); + } + + /** + * Determines the current namespace on the specified buffer position. + * + * @param {TextEditor} editor The editor that contains the class (needed to resolve relative class names). + * @param {Point} bufferPosition + * + * @return {Promise} + */ + determineCurrentNamespaceName(editor, bufferPosition) { + return new Promise((resolve, reject) => { + const path = editor.getPath(); + + if ((path == null)) { + reject(); + return; + } + + const successHandler = namespacesInFile => { + for (let id in namespacesInFile) { + const namespace = namespacesInFile[id]; + if ((bufferPosition.row >= namespace.startLine) && (bufferPosition.row <= namespace.endLine)) { + resolve(namespace.name); + return; + } + } + + return resolve(null); + }; + + const failureHandler = () => { + return reject(); + }; + + return this.getNamespaceListForFile(path).then(successHandler, failureHandler); + }); + } + + /** + * Convenience function that resolves types using {@see resolveType}, automatically determining the correct + * parameters for the editor and buffer position. + * + * @param {TextEditor} editor The editor. + * @param {Point} bufferPosition The location of the type. + * @param {String} type The (local) type to resolve. + * @param {String} kind The kind of element. Either 'classlike', 'constant' or 'function'. + * + * @return {Promise} + * + * @example In a file with namespace A\B, determining C could lead to A\B\C. + */ + resolveTypeAt(editor, bufferPosition, type, kind) { + return this.resolveType(editor.getPath(), bufferPosition.row + 1, type, kind); + } + + /** + * Retrieves all variables that are available at the specified buffer position. + * + * @param {TextEditor} editor + * @param {Range} bufferPosition + * + * @return {Promise} + */ + getAvailableVariables(editor, bufferPosition) { + const offset = editor.getBuffer().characterIndexForPosition(bufferPosition); + + return this.getAvailableVariablesByOffset(editor.getPath(), editor.getBuffer().getText(), offset); + } + + /** + * Retrieves the types that are being used (called) at the specified location in the buffer. Note that this does not + * guarantee that the returned types actually exist. You can use {@see getClassInfo} on the returned class name + * to check for this instead. + * + * @param {TextEditor} editor The text editor to use. + * @param {Point} bufferPosition The cursor location of the item, such as the class member. Note that this + * should always be at the end of the actual member (i.e. just after it). + * If you want to ignore the element at the buffer position itself, see + * 'ignoreLastElement'. + * @param {boolean} ignoreLastElement Whether to remove the last element or not, this is useful when the user + * is still writing code, e.g. "$this->foo()->b" would normally return the + * type (class) of 'b', as it is the last element, but as the user is still + * writing code, you may instead be interested in the type of 'foo()' instead. + * + * @return {Promise} + * + * @example Invoking it on MyMethod::foo()->bar() will ask what class 'bar' is invoked on, which will whatever types + * foo returns. + */ + getResultingTypesAt(editor, bufferPosition, ignoreLastElement) { + const offset = editor.getBuffer().characterIndexForPosition(bufferPosition); + + const bufferText = editor.getBuffer().getText(); + + return this.deduceTypes(null, editor.getPath(), bufferText, offset, true); + } + + /** + * Retrieves the call stack of the function or method that is being invoked at the specified position. This can be + * used to fetch information about the function or method call the cursor is in. + * + * @param {TextEditor} editor + * @param {Point} bufferPosition + * + * @return {Promise} With elements 'callStack' (array) as well as 'argumentIndex' which denotes the argument in the + * parameter list the position is located at. Returns 'null' if not in a method or function call. + * + * @example "$this->test(1, function () {},| 2);" (where the vertical bar denotes the cursor position) will yield + * ['$this', 'test']. + */ + getInvocationInfoAt(editor, bufferPosition) { + const offset = editor.getBuffer().characterIndexForPosition(bufferPosition); + + const bufferText = editor.getBuffer().getText(); + + return this.getInvocationInfo(editor.getPath(), bufferText, offset); + } + + /** + * Creates a popover with the specified constructor arguments. + */ + createPopover() { + return new Popover(...arguments); + } + + /** + * Creates an attached popover with the specified constructor arguments. + */ + createAttachedPopover() { + return new AttachedPopover(...arguments); + } + + /** + * Indicates if the specified type is a basic type (e.g. int, array, object, etc.). + * + * @param {String} type + * + * @return {boolean} + */ + isBasicType(type) { + return /^(string|int|bool|float|object|mixed|array|resource|void|null|callable|false|true|self|static|parent|\$this)$/i.test(type); + } + + /** + * Utility function to convert byte offsets returned by the service into character offsets. + * + * @param {Number} byteOffset + * @param {String} string + * + * @return {Number} + */ + getCharacterOffsetFromByteOffset(byteOffset, string) { + const buffer = new Buffer(string); + + return buffer.slice(0, byteOffset).toString().length; + } + + /** + * @param {String} fqcn + * + * @return {String} + */ + getDocumentationUrlForClass(fqcn) { + return this.config.get('php_documentation_base_urls').classes + this.getNormalizedFqcnDocumentationUrl(fqcn); + } + + /** + * @param {String} fqcn + * + * @return {String} + */ + getDocumentationUrlForFunction(fqcn) { + return this.config.get('php_documentation_base_urls').functions + this.getNormalizedFqcnDocumentationUrl(fqcn); + } + + /** + * @param {String} classlikeFqcn + * @param {String} name + * + * @return {String} + */ + getDocumentationUrlForClassMethod(classlikeFqcn, name) { + return this.config.get('php_documentation_base_urls').root + this.getNormalizedFqcnDocumentationUrl(classlikeFqcn) + '.' + this.getNormalizeMethodDocumentationUrl(name); + } + + /** + * @param {String} name + * + * @return {String} + */ + getNormalizedFqcnDocumentationUrl(name) { + if ((name.length > 0) && (name[0] === '\\')) { + name = name.substr(1); + } + + return name.replace(/\\/g, '-').toLowerCase(); + } + + /** + * @param {String} name + * + * @return {String} + */ + getNormalizeMethodDocumentationUrl(name) { + return name.replace(/^__/, ''); + } + }; + Service.initClass(); + return Service; +})()); diff --git a/lib/SignatureHelpProvider.coffee b/lib/SignatureHelpProvider.coffee deleted file mode 100644 index 2bf9780b..00000000 --- a/lib/SignatureHelpProvider.coffee +++ /dev/null @@ -1,71 +0,0 @@ -module.exports = - -##* -# Provides signature help. -## -class SignatureHelpProvider - ###* - * The service (that can be used to query the source code and contains utility methods). - * - * @var {Object|null} - ### - service: null - - ###* - * @var {Array} - ### - grammarScopes: ['text.html.php'] - - ###* - * @var {Number} - ### - priority: 1 - - ###* - * @var {Set|null} - ### - triggerCharacters: null - - ###* - * @var {CancellablePromise} - ### - pendingRequestPromise: null - - ###* - * Constructor. - ### - constructor: () -> - @triggerCharacters = new Set(['(', ',']) - - ###* - * Initializes this provider. - * - * @param {mixed} service - ### - activate: (@service) -> - - ###* - * Deactives the provider. - ### - deactivate: () -> - - ###* - * @param {TextEditor} editor - * @param {Point} point - * - * @return {Promise} - ### - getSignatureHelp: (editor, point) -> - if @pendingRequestPromise? - @pendingRequestPromise.cancel() - @pendingRequestPromise = null - - successHandler = (signatureHelp) => - return signatureHelp - - failureHandler = () => - return null - - @pendingRequestPromise = @service.signatureHelpAt(editor, point) - - return @pendingRequestPromise.then(successHandler, failureHandler) diff --git a/lib/SignatureHelpProvider.js b/lib/SignatureHelpProvider.js new file mode 100644 index 00000000..d67cbb92 --- /dev/null +++ b/lib/SignatureHelpProvider.js @@ -0,0 +1,92 @@ +/* + * decaffeinate suggestions: + * DS206: Consider reworking classes to avoid initClass + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let SignatureHelpProvider; +module.exports = + +//#* +// Provides signature help. +//# +(SignatureHelpProvider = (function() { + SignatureHelpProvider = class SignatureHelpProvider { + static initClass() { + /** + * The service (that can be used to query the source code and contains utility methods). + * + * @var {Object|null} + */ + this.prototype.service = null; + + /** + * @var {Array} + */ + this.prototype.grammarScopes = ['text.html.php']; + + /** + * @var {Number} + */ + this.prototype.priority = 1; + + /** + * @var {Set|null} + */ + this.prototype.triggerCharacters = null; + + /** + * @var {CancellablePromise} + */ + this.prototype.pendingRequestPromise = null; + } + + /** + * Constructor. + */ + constructor() { + this.triggerCharacters = new Set(['(', ',']); + } + + /** + * Initializes this provider. + * + * @param {mixed} service + */ + activate(service) { + this.service = service; + } + + /** + * Deactives the provider. + */ + deactivate() {} + + /** + * @param {TextEditor} editor + * @param {Point} point + * + * @return {Promise} + */ + getSignatureHelp(editor, point) { + if (this.pendingRequestPromise != null) { + this.pendingRequestPromise.cancel(); + this.pendingRequestPromise = null; + } + + const successHandler = signatureHelp => { + return signatureHelp; + }; + + const failureHandler = () => { + return null; + }; + + this.pendingRequestPromise = this.service.signatureHelpAt(editor, point); + + return this.pendingRequestPromise.then(successHandler, failureHandler); + } + }; + SignatureHelpProvider.initClass(); + return SignatureHelpProvider; +})()); diff --git a/lib/SymbolHelpers.coffee b/lib/SymbolHelpers.js similarity index 50% rename from lib/SymbolHelpers.coffee rename to lib/SymbolHelpers.js index d8c9db0a..3ee8346b 100644 --- a/lib/SymbolHelpers.coffee +++ b/lib/SymbolHelpers.js @@ -1,9 +1,9 @@ -{Range} = require 'atom' +const {Range} = require('atom'); -LanguageClientUtils = require '../node_modules/atom-languageclient/build/lib/utils' +const LanguageClientUtils = require('../node_modules/atom-languageclient/build/lib/utils'); -module.exports = - ###* +module.exports = { + /** * Slightly modified variant of the LanguageClientUtils getWordAtPosition. * * This variant does not treat the backslash as a non-word character, so it is not treated as boundary and the range @@ -14,20 +14,23 @@ module.exports = * @param {Point} position * * @see https://github.com/atom/atom-languageclient/blob/master/lib/utils.js - ### - getRangeForSymbolAtPosition: (editor, position) -> - scopeDescriptor = editor.scopeDescriptorForBufferPosition(position) - nonWordCharacters = editor.getNonWordCharacters(scopeDescriptor) - nonWordCharacters = nonWordCharacters.replace("\\", '') - nonWordCharacters = LanguageClientUtils.escapeRegExp(nonWordCharacters) + */ + getRangeForSymbolAtPosition(editor, position) { + const scopeDescriptor = editor.scopeDescriptorForBufferPosition(position); + let nonWordCharacters = editor.getNonWordCharacters(scopeDescriptor); + nonWordCharacters = nonWordCharacters.replace("\\", ''); + nonWordCharacters = LanguageClientUtils.escapeRegExp(nonWordCharacters); - range = LanguageClientUtils._getRegexpRangeAtPosition( + const range = LanguageClientUtils._getRegexpRangeAtPosition( editor.getBuffer(), position, - ///^[\t ]*$|[^\s#{nonWordCharacters}]+///g - ) + new RegExp(`^[\\t]*$|[^\\s${nonWordCharacters}]+`, 'g') + ); - if range == null + if (range === null) { return new Range(position, position); + } - return range + return range; + } +}; diff --git a/lib/UseStatementHelper.coffee b/lib/UseStatementHelper.coffee deleted file mode 100644 index fc9b0a6d..00000000 --- a/lib/UseStatementHelper.coffee +++ /dev/null @@ -1,237 +0,0 @@ -module.exports = - -##* -# Contains convenience methods for dealing with use statements. -## -class UseStatementHelper - ###* - * Regular expression that will search for a structure (class, interface, trait, ...). - * - * @var {RegExp} - ### - structureStartRegex : /(?:abstract class|class|trait|interface)\s+(\w+)/ - - ###* - * Regular expression that will search for a use statement. - * - * @var {RegExp} - ### - useStatementRegex : /(?:use)(?:[^\w\\])([\w\\]+)(?![\w\\])(?:(?:[ ]+as[ ]+)(\w+))?(?:;)/ - - ###* - * Whether to allow adding additional newlines to attempt to group use statements. - * - * @var {Boolean} - ### - allowAdditionalNewlines : true - - ###* - * @param {Boolean} allowAdditionalNewlines - ### - constructor: (@allowAdditionalNewlines) -> - - ###* - * @param {Boolean} allowAdditionalNewlines - ### - setAllowAdditionalNewlines: (@allowAdditionalNewlines) -> - - ###* - * Add the use for the given class if not already added. - * - * @param {TextEditor} editor Atom text editor. - * @param {String} className Name of the class to add. - * - * @return {Number} The amount of lines added (including newlines), so you can reliably and easily offset your rows. - # This could be zero if a use statement was already present. - ### - addUseClass: (editor, className) -> - bestUseRow = 0 - placeBelow = true - doNewLine = true - lineCount = editor.getLineCount() - previousMatchThatSharedNamespacePrefixRow = null - - # First see if the use statement is already present. The next loop stops early (and can't do this). - for i in [0 .. lineCount - 1] - line = editor.lineTextForBufferRow(i).trim() - - continue if line.length == 0 - - scopeDescriptor = editor.scopeDescriptorForBufferPosition([i, line.length]).getScopeChain() - - if scopeDescriptor.indexOf('.comment') >= 0 - continue - - break if line.match(@structureStartRegex) - - if (matches = @useStatementRegex.exec(line)) - if matches[1] == className or (matches[1][0] == '\\' and matches[1].substr(1) == className) - return 0 - - # Determine an appropriate location to place the use statement. - for i in [0 .. lineCount - 1] - line = editor.lineTextForBufferRow(i).trim() - - continue if line.length == 0 - - scopeDescriptor = editor.scopeDescriptorForBufferPosition([i, line.length]).getScopeChain() - - if scopeDescriptor.indexOf('.comment') >= 0 - continue - - break if line.match(@structureStartRegex) - - if line.indexOf('namespace ') >= 0 - bestUseRow = i - - if (matches = @useStatementRegex.exec(line)) - bestUseRow = i - - placeBelow = true - shareCommonNamespacePrefix = @doShareCommonNamespacePrefix(className, matches[1]) - - doNewLine = not shareCommonNamespacePrefix - - if @scoreClassName(className, matches[1]) <= 0 - placeBelow = false - - # Normally we keep going until the sorting indicates we should stop, and then place the use - # statement above the 'incorrect' match, but if the previous use statement was a use statement - # that has the same namespace, we want to ensure we stick close to it instead of creating additional - # newlines (which the item from the same namespace already placed). - if previousMatchThatSharedNamespacePrefixRow? - placeBelow = true - doNewLine = false - bestUseRow = previousMatchThatSharedNamespacePrefixRow - - break - - previousMatchThatSharedNamespacePrefixRow = if shareCommonNamespacePrefix then i else null - - # Insert the use statement itself. - lineEnding = editor.getBuffer().lineEndingForRow(0) - - if not @allowAdditionalNewlines - doNewLine = false - - if not lineEnding - lineEnding = "\n" - - textToInsert = '' - - if doNewLine and placeBelow - textToInsert += lineEnding - - textToInsert += "use #{className};" + lineEnding - - if doNewLine and not placeBelow - textToInsert += lineEnding - - lineToInsertAt = bestUseRow + (if placeBelow then 1 else 0) - editor.setTextInBufferRange([[lineToInsertAt, 0], [lineToInsertAt, 0]], textToInsert) - - return (1 + (if doNewLine then 1 else 0)) - - ###* - * Returns a boolean indicating if the specified class names share a common namespace prefix. - * - * @param {String} firstClassName - * @param {String} secondClassName - * - * @return {Boolean} - ### - doShareCommonNamespacePrefix: (firstClassName, secondClassName) -> - firstClassNameParts = firstClassName.split('\\') - secondClassNameParts = secondClassName.split('\\') - - firstClassNameParts.pop() - secondClassNameParts.pop() - - return if firstClassNameParts.join('\\') == secondClassNameParts.join('\\') then true else false - - ###* - * Scores the first class name against the second, indicating how much they 'match' each other. This can be used - * to e.g. find an appropriate location to place a class in an existing list of classes. - * - * @param {String} firstClassName - * @param {String} secondClassName - * - * @return {Number} A floating point number that represents the score. - ### - scoreClassName: (firstClassName, secondClassName) -> - maxLength = 0 - totalScore = 0 - - firstClassNameParts = firstClassName.split('\\') - secondClassNameParts = secondClassName.split('\\') - - maxLength = Math.min(firstClassNameParts.length, secondClassNameParts.length) - - collator = new Intl.Collator - - # At this point, both FQSEN's share a common namespace, e.g. A\B and A\B\C\D, or XMLElement and XMLDocument. - # The one with the most namespace parts ends up last. - if firstClassNameParts.length < secondClassNameParts.length - return -1 - - else if firstClassNameParts.length > secondClassNameParts.length - return 1 - - if maxLength >= 2 - for i in [0 .. maxLength - 1] - if firstClassNameParts[i] != secondClassNameParts[i] - if firstClassNameParts[i].length == secondClassNameParts[i].length - return collator.compare(firstClassNameParts[i], secondClassNameParts[i]) - - return firstClassNameParts[i].length > secondClassNameParts[i].length ? 1 : -1 - - if firstClassName.length == secondClassName.length - return collator.compare(firstClassName, secondClassName) - - # Both items have share the same namespace, sort from shortest to longest last word (class, interface, ...). - return firstClassName.length > secondClassName.length ? 1 : -1 - - ###* - * Sorts the use statements in the specified file according to the same algorithm used by 'addUseClass'. - * - * @param {TextEditor} editor - ### - sortUseStatements: (editor) -> - endLine = null - startLine = null - useStatements = [] - - for i in [0 .. editor.getLineCount()] - lineText = editor.lineTextForBufferRow(i) - - endLine = i - - if not lineText or lineText.trim() == '' - continue - - else if (matches = @useStatementRegex.exec(lineText)) - if not startLine - startLine = i - - text = matches[1] - - if matches[2]? - text += ' as ' + matches[2] - - useStatements.push(text); - - # We still do the regex check here to prevent continuing when there are no use statements at all. - else if startLine or @structureStartRegex.test(lineText) - break - - return if useStatements.length == 0 - - editor.transact () => - editor.setTextInBufferRange([[startLine, 0], [endLine, 0]], '') - - for useStatement in useStatements - # The leading slash is unnecessary, not recommended, and messes up sorting, take it out. - if useStatement[0] == '\\' - useStatement = useStatement.substr(1) - - @addUseClass(editor, useStatement, @allowAdditionalNewlines) diff --git a/lib/UseStatementHelper.js b/lib/UseStatementHelper.js new file mode 100644 index 00000000..42f750be --- /dev/null +++ b/lib/UseStatementHelper.js @@ -0,0 +1,301 @@ +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS104: Avoid inline assignments + * DS202: Simplify dynamic range loops + * DS205: Consider reworking code to avoid use of IIFEs + * DS206: Consider reworking classes to avoid initClass + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let UseStatementHelper; +module.exports = + +//#* +// Contains convenience methods for dealing with use statements. +//# +(UseStatementHelper = (function() { + UseStatementHelper = class UseStatementHelper { + static initClass() { + /** + * Regular expression that will search for a structure (class, interface, trait, ...). + * + * @var {RegExp} + */ + this.prototype.structureStartRegex = /(?:abstract class|class|trait|interface)\s+(\w+)/; + + /** + * Regular expression that will search for a use statement. + * + * @var {RegExp} + */ + this.prototype.useStatementRegex = /(?:use)(?:[^\w\\])([\w\\]+)(?![\w\\])(?:(?:[ ]+as[ ]+)(\w+))?(?:;)/; + + /** + * Whether to allow adding additional newlines to attempt to group use statements. + * + * @var {Boolean} + */ + this.prototype.allowAdditionalNewlines = true; + } + + /** + * @param {Boolean} allowAdditionalNewlines + */ + constructor(allowAdditionalNewlines) { + this.allowAdditionalNewlines = allowAdditionalNewlines; + } + + /** + * @param {Boolean} allowAdditionalNewlines + */ + setAllowAdditionalNewlines(allowAdditionalNewlines) { + this.allowAdditionalNewlines = allowAdditionalNewlines; + } + + /** + * Add the use for the given class if not already added. + * + * @param {TextEditor} editor Atom text editor. + * @param {String} className Name of the class to add. + * + * @return {Number} The amount of lines added (including newlines), so you can reliably and easily offset your rows. + * This could be zero if a use statement was already present. + */ + addUseClass(editor, className) { + let i, line, matches, scopeDescriptor; + let asc, end; + let asc1, end1; + let bestUseRow = 0; + let placeBelow = true; + let doNewLine = true; + const lineCount = editor.getLineCount(); + let previousMatchThatSharedNamespacePrefixRow = null; + + // First see if the use statement is already present. The next loop stops early (and can't do this). + for (i = 0, end = lineCount - 1, asc = 0 <= end; asc ? i <= end : i >= end; asc ? i++ : i--) { + line = editor.lineTextForBufferRow(i).trim(); + + if (line.length === 0) { continue; } + + scopeDescriptor = editor.scopeDescriptorForBufferPosition([i, line.length]).getScopeChain(); + + if (scopeDescriptor.indexOf('.comment') >= 0) { + continue; + } + + if (line.match(this.structureStartRegex)) { break; } + + if (matches = this.useStatementRegex.exec(line)) { + if ((matches[1] === className) || ((matches[1][0] === '\\') && (matches[1].substr(1) === className))) { + return 0; + } + } + } + + // Determine an appropriate location to place the use statement. + for (i = 0, end1 = lineCount - 1, asc1 = 0 <= end1; asc1 ? i <= end1 : i >= end1; asc1 ? i++ : i--) { + line = editor.lineTextForBufferRow(i).trim(); + + if (line.length === 0) { continue; } + + scopeDescriptor = editor.scopeDescriptorForBufferPosition([i, line.length]).getScopeChain(); + + if (scopeDescriptor.indexOf('.comment') >= 0) { + continue; + } + + if (line.match(this.structureStartRegex)) { break; } + + if (line.indexOf('namespace ') >= 0) { + bestUseRow = i; + } + + if (matches = this.useStatementRegex.exec(line)) { + bestUseRow = i; + + placeBelow = true; + const shareCommonNamespacePrefix = this.doShareCommonNamespacePrefix(className, matches[1]); + + doNewLine = !shareCommonNamespacePrefix; + + if (this.scoreClassName(className, matches[1]) <= 0) { + placeBelow = false; + + // Normally we keep going until the sorting indicates we should stop, and then place the use + // statement above the 'incorrect' match, but if the previous use statement was a use statement + // that has the same namespace, we want to ensure we stick close to it instead of creating additional + // newlines (which the item from the same namespace already placed). + if (previousMatchThatSharedNamespacePrefixRow != null) { + placeBelow = true; + doNewLine = false; + bestUseRow = previousMatchThatSharedNamespacePrefixRow; + } + + break; + } + + previousMatchThatSharedNamespacePrefixRow = shareCommonNamespacePrefix ? i : null; + } + } + + // Insert the use statement itself. + let lineEnding = editor.getBuffer().lineEndingForRow(0); + + if (!this.allowAdditionalNewlines) { + doNewLine = false; + } + + if (!lineEnding) { + lineEnding = "\n"; + } + + let textToInsert = ''; + + if (doNewLine && placeBelow) { + textToInsert += lineEnding; + } + + textToInsert += `use ${className};` + lineEnding; + + if (doNewLine && !placeBelow) { + textToInsert += lineEnding; + } + + const lineToInsertAt = bestUseRow + (placeBelow ? 1 : 0); + editor.setTextInBufferRange([[lineToInsertAt, 0], [lineToInsertAt, 0]], textToInsert); + + return (1 + (doNewLine ? 1 : 0)); + } + + /** + * Returns a boolean indicating if the specified class names share a common namespace prefix. + * + * @param {String} firstClassName + * @param {String} secondClassName + * + * @return {Boolean} + */ + doShareCommonNamespacePrefix(firstClassName, secondClassName) { + const firstClassNameParts = firstClassName.split('\\'); + const secondClassNameParts = secondClassName.split('\\'); + + firstClassNameParts.pop(); + secondClassNameParts.pop(); + + if (firstClassNameParts.join('\\') === secondClassNameParts.join('\\')) { return true; } else { return false; } + } + + /** + * Scores the first class name against the second, indicating how much they 'match' each other. This can be used + * to e.g. find an appropriate location to place a class in an existing list of classes. + * + * @param {String} firstClassName + * @param {String} secondClassName + * + * @return {Number} A floating point number that represents the score. + */ + scoreClassName(firstClassName, secondClassName) { + let left1; + let maxLength = 0; + const totalScore = 0; + + const firstClassNameParts = firstClassName.split('\\'); + const secondClassNameParts = secondClassName.split('\\'); + + maxLength = Math.min(firstClassNameParts.length, secondClassNameParts.length); + + const collator = new Intl.Collator; + + // At this point, both FQSEN's share a common namespace, e.g. A\B and A\B\C\D, or XMLElement and XMLDocument. + // The one with the most namespace parts ends up last. + if (firstClassNameParts.length < secondClassNameParts.length) { + return -1; + + } else if (firstClassNameParts.length > secondClassNameParts.length) { + return 1; + } + + if (maxLength >= 2) { + for (let i = 0, end = maxLength - 1, asc = 0 <= end; asc ? i <= end : i >= end; asc ? i++ : i--) { + if (firstClassNameParts[i] !== secondClassNameParts[i]) { + var left; + if (firstClassNameParts[i].length === secondClassNameParts[i].length) { + return collator.compare(firstClassNameParts[i], secondClassNameParts[i]); + } + + return (left = firstClassNameParts[i].length > secondClassNameParts[i].length) != null ? left : {1 : -1}; + } + } + } + + if (firstClassName.length === secondClassName.length) { + return collator.compare(firstClassName, secondClassName); + } + + // Both items have share the same namespace, sort from shortest to longest last word (class, interface, ...). + return (left1 = firstClassName.length > secondClassName.length) != null ? left1 : {1 : -1}; + } + + /** + * Sorts the use statements in the specified file according to the same algorithm used by 'addUseClass'. + * + * @param {TextEditor} editor + */ + sortUseStatements(editor) { + let endLine = null; + let startLine = null; + const useStatements = []; + + for (let i = 0, end = editor.getLineCount(), asc = 0 <= end; asc ? i <= end : i >= end; asc ? i++ : i--) { + var matches; + const lineText = editor.lineTextForBufferRow(i); + + endLine = i; + + if (!lineText || (lineText.trim() === '')) { + continue; + + } else if (matches = this.useStatementRegex.exec(lineText)) { + if (!startLine) { + startLine = i; + } + + let text = matches[1]; + + if (matches[2] != null) { + text += ` as ${matches[2]}`; + } + + useStatements.push(text); + + // We still do the regex check here to prevent continuing when there are no use statements at all. + } else if (startLine || this.structureStartRegex.test(lineText)) { + break; + } + } + + if (useStatements.length === 0) { return; } + + return editor.transact(() => { + editor.setTextInBufferRange([[startLine, 0], [endLine, 0]], ''); + + return (() => { + const result = []; + for (let useStatement of Array.from(useStatements)) { + // The leading slash is unnecessary, not recommended, and messes up sorting, take it out. + if (useStatement[0] === '\\') { + useStatement = useStatement.substr(1); + } + + result.push(this.addUseClass(editor, useStatement, this.allowAdditionalNewlines)); + } + return result; + })(); + }); + } + }; + UseStatementHelper.initClass(); + return UseStatementHelper; +})()); diff --git a/lib/Widgets/AttachedPopover.coffee b/lib/Widgets/AttachedPopover.coffee deleted file mode 100644 index 5fd8a014..00000000 --- a/lib/Widgets/AttachedPopover.coffee +++ /dev/null @@ -1,68 +0,0 @@ -Popover = require './Popover' - -module.exports = - -##* -# Popover that is attached to an HTML element. -# -# NOTE: The reason we do not use Atom's native tooltip is because it is attached to an element, which caused strange -# problems such as tickets #107 and #72. This implementation uses the same CSS classes and transitions but handles the -# displaying manually as we don't want to attach/detach, we only want to temporarily display a popover on mouseover. -## -class AttachedPopover extends Popover - ###* - * Timeout ID, used for setting a timeout before displaying the popover. - ### - timeoutId: null - - ###* - * The element to attach the popover to. - ### - elementToAttachTo: null - - ###* - * Constructor. - * - * @param {HTMLElement} elementToAttachTo The element to show the popover over. - * @param {Number} delay How long the mouse has to hover over the elment before the popover shows - * up (in miliiseconds). - ### - constructor: (@elementToAttachTo, delay = 500) -> - super() - - ###* - * Destructor. - ### - destructor: () -> - if @timeoutId - clearTimeout(@timeoutId) - @timeoutId = null - - super() - - ###* - * Shows the popover with the specified text. - ### - show: () -> - coordinates = @elementToAttachTo.getBoundingClientRect(); - - centerOffset = ((coordinates.right - coordinates.left) / 2) - - x = coordinates.left - (@getElement().offsetWidth / 2) + centerOffset - y = coordinates.bottom - - x = 0 if x < 0 - y = 0 if y < 0 - - super(x, y) - - ###* - * Shows the popover with the specified text after the specified delay (in miliiseconds). Calling this method - * multiple times will cancel previous show requests and restart. - * - * @param {Number} delay The delay before the tooltip shows up (in milliseconds). - ### - showAfter: (delay) -> - @timeoutId = setTimeout(() => - @show() - , delay) diff --git a/lib/Widgets/AttachedPopover.js b/lib/Widgets/AttachedPopover.js new file mode 100644 index 00000000..b2fe70dd --- /dev/null +++ b/lib/Widgets/AttachedPopover.js @@ -0,0 +1,95 @@ +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * DS206: Consider reworking classes to avoid initClass + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let AttachedPopover; +const Popover = require('./Popover'); + +module.exports = + +//#* +// Popover that is attached to an HTML element. +// +// NOTE: The reason we do not use Atom's native tooltip is because it is attached to an element, which caused strange +// problems such as tickets #107 and #72. This implementation uses the same CSS classes and transitions but handles the +// displaying manually as we don't want to attach/detach, we only want to temporarily display a popover on mouseover. +//# +(AttachedPopover = (function() { + AttachedPopover = class AttachedPopover extends Popover { + static initClass() { + /** + * Timeout ID, used for setting a timeout before displaying the popover. + */ + this.prototype.timeoutId = null; + + /** + * The element to attach the popover to. + */ + this.prototype.elementToAttachTo = null; + } + + /** + * Constructor. + * + * @param {HTMLElement} elementToAttachTo The element to show the popover over. + * @param {Number} delay How long the mouse has to hover over the elment before the popover shows + * up (in miliiseconds). + */ + constructor(elementToAttachTo, delay) { + super(); + + this.elementToAttachTo = elementToAttachTo; + + if (delay == null) { + delay = 500; + } + } + + /** + * Destructor. + */ + destructor() { + if (this.timeoutId) { + clearTimeout(this.timeoutId); + this.timeoutId = null; + } + + return super.destructor(); + } + + /** + * Shows the popover with the specified text. + */ + show() { + const coordinates = this.elementToAttachTo.getBoundingClientRect(); + + const centerOffset = ((coordinates.right - coordinates.left) / 2); + + let x = (coordinates.left - (this.getElement().offsetWidth / 2)) + centerOffset; + let y = coordinates.bottom; + + if (x < 0) { x = 0; } + if (y < 0) { y = 0; } + + return super.show(x, y); + } + + /** + * Shows the popover with the specified text after the specified delay (in miliiseconds). Calling this method + * multiple times will cancel previous show requests and restart. + * + * @param {Number} delay The delay before the tooltip shows up (in milliseconds). + */ + showAfter(delay) { + return this.timeoutId = setTimeout(() => { + return this.show(); + } + , delay); + } + }; + AttachedPopover.initClass(); + return AttachedPopover; +})()); diff --git a/lib/Widgets/Popover.coffee b/lib/Widgets/Popover.coffee deleted file mode 100644 index ba3d625c..00000000 --- a/lib/Widgets/Popover.coffee +++ /dev/null @@ -1,65 +0,0 @@ -{Disposable} = require 'atom' - -module.exports = - -##* -# Widget that can be used to display information about a certain context. -## -class Popover extends Disposable - element: null - - ###* - * Constructor. - ### - constructor: () -> - @element = document.createElement('div') - @element.className = 'tooltip bottom fade php-ide-serenata-popover' - @element.innerHTML = "
    " - - document.body.appendChild(@element) - - super @destructor - - ###* - * Destructor. - ### - destructor: () -> - @hide() - document.body.removeChild(@element) - - ###* - * Retrieves the HTML element containing the popover. - * - * @return {HTMLElement} - ### - getElement: () -> - return @element - - ###* - * sets the text to display. - * - * @param {String} text - ### - setText: (text) -> - item = @element.querySelector('.tooltip-inner') - item.innerHTML = '
    ' + text.replace(/\n\n/g, '

    ') + '
    ' - - ###* - * Shows a popover at the specified location with the specified text and fade in time. - * - * @param {Number} x The X coordinate to show the popover at (left). - * @param {Number} y The Y coordinate to show the popover at (top). - ### - show: (x, y) -> - @element.style.left = x + 'px' - @element.style.top = y + 'px' - - @element.classList.add('in') - @element.style.display = 'block' - - ###* - * Hides the tooltip, if it is displayed. - ### - hide: () -> - @element.classList.remove('in') - @element.style.display = 'none' diff --git a/lib/Widgets/Popover.js b/lib/Widgets/Popover.js new file mode 100644 index 00000000..3611bc4f --- /dev/null +++ b/lib/Widgets/Popover.js @@ -0,0 +1,85 @@ +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * DS206: Consider reworking classes to avoid initClass + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let Popover; +const {Disposable} = require('atom'); + +module.exports = + +//#* +// Widget that can be used to display information about a certain context. +//# +(Popover = (function() { + Popover = class Popover extends Disposable { + static initClass() { + this.prototype.element = null; + } + + /** + * Constructor. + */ + constructor() { + super(() => { this.destructor(); }); + + this.element = document.createElement('div'); + this.element.className = 'tooltip bottom fade php-ide-serenata-popover'; + this.element.innerHTML = "
    "; + + document.body.appendChild(this.element); + } + + /** + * Destructor. + */ + destructor() { + this.hide(); + return document.body.removeChild(this.element); + } + + /** + * Retrieves the HTML element containing the popover. + * + * @return {HTMLElement} + */ + getElement() { + return this.element; + } + + /** + * sets the text to display. + * + * @param {String} text + */ + setText(text) { + const item = this.element.querySelector('.tooltip-inner'); + return item.innerHTML = `
    ${text.replace(/\n\n/g, '

    ')}
    `; + } + + /** + * Shows a popover at the specified location with the specified text and fade in time. + * + * @param {Number} x The X coordinate to show the popover at (left). + * @param {Number} y The Y coordinate to show the popover at (top). + */ + show(x, y) { + this.element.style.left = x + 'px'; + this.element.style.top = y + 'px'; + + this.element.classList.add('in'); + return this.element.style.display = 'block'; + } + + /** + * Hides the tooltip, if it is displayed. + */ + hide() { + this.element.classList.remove('in'); + return this.element.style.display = 'none'; + } + }; + Popover.initClass(); + return Popover; +})());