├── .eslintrc.yml ├── .github └── FUNDING.yml ├── COPYING ├── LICENSE ├── LICENSE-atom-autocomplete-php ├── keymaps └── php-ide-serenata.cson ├── lib ├── AtomConfig.js ├── CancellablePromise.js ├── CodeLensManager.js ├── Config.js ├── ConfigTester.js ├── Main.js ├── PhpInvoker.js ├── ProjectManager.js ├── Proxy.js ├── Refactoring │ ├── AbstractProvider.js │ ├── ConstructorGenerationProvider.js │ ├── ConstructorGenerationProvider │ │ └── View.js │ ├── DocblockProvider.js │ ├── ExtractMethodProvider.js │ ├── ExtractMethodProvider │ │ ├── Builder.js │ │ ├── ParameterParser.js │ │ └── View.js │ ├── GetterSetterProvider.js │ ├── GetterSetterProvider │ │ └── View.js │ ├── IntroducePropertyProvider.js │ ├── OverrideMethodProvider.js │ ├── OverrideMethodProvider │ │ └── View.js │ ├── StubAbstractMethodProvider.js │ ├── StubAbstractMethodProvider │ │ └── View.js │ ├── StubInterfaceMethodProvider.js │ ├── StubInterfaceMethodProvider │ │ └── View.js │ └── Utility │ │ ├── DocblockBuilder.js │ │ ├── FunctionBuilder.js │ │ ├── MultiSelectionView.js │ │ └── TypeHelper.js ├── SerenataClient.js ├── ServerManager.js ├── ServiceContainer.js └── UseStatementHelper.js ├── menus └── menu.cson ├── package-lock.json ├── package.json └── styles └── main.less /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | browser: true 3 | node: true 4 | es6: true 5 | atomtest: true 6 | extends: 'eslint:recommended' 7 | parserOptions: 8 | sourceType: module 9 | ecmaVersion: 2017 10 | rules: 11 | indent: 12 | - error 13 | - 4 14 | linebreak-style: 15 | - error 16 | - unix 17 | quotes: 18 | - error 19 | - single 20 | - 21 | allowTemplateLiterals: true 22 | semi: 23 | - error 24 | - always 25 | no-console: 26 | - off 27 | max-len: 28 | - error 29 | - 30 | code: 120 31 | no-cond-assign: 32 | - off 33 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | #github: [Gert-dev] 4 | #patreon: # Replace with a single Patreon username 5 | #open_collective: # Replace with a single Open Collective username 6 | #ko_fi: # Replace with a single Ko-fi username 7 | #tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | #community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: Gert-dev 10 | #issuehunt: # Replace with a single IssueHunt username 11 | #otechie: # Replace with a single Otechie username 12 | #custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | custom: ['https://www.paypal.me/gertdev'] 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | © 2015 Tom Gerrits 2 | 3 | This program is free software: you can redistribute it and/or modify 4 | it under the terms of the GNU General Public License as published by 5 | the Free Software Foundation, either version 3 of the License, or 6 | (at your option) any later version. 7 | 8 | This program is distributed in the hope that it will be useful, 9 | but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | GNU General Public License for more details. 12 | 13 | You should have received a copy of the GNU General Public License 14 | along with this program. If not, see . 15 | -------------------------------------------------------------------------------- /LICENSE-atom-autocomplete-php: -------------------------------------------------------------------------------- 1 | This project was forked from atom-autocomplete-php, thus the original code base 2 | was licensed under the MIT license. It can still be found at [1]. The original 3 | license is located below. 4 | 5 | [1] https://github.com/Peekmo/atom-autocomplete-php 6 | 7 | The MIT License (MIT) 8 | 9 | Copyright (c) 2014-2015 Axel Anceau 10 | 11 | Permission is hereby granted, free of charge, to any person obtaining a copy of 12 | this software and associated documentation files (the "Software"), to deal in 13 | the Software without restriction, including without limitation the rights to 14 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 15 | the Software, and to permit persons to whom the Software is furnished to do so, 16 | subject to the following conditions: 17 | 18 | The above copyright notice and this permission notice shall be included in all 19 | copies or substantial portions of the Software. 20 | 21 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 22 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 23 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 24 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 25 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 26 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 27 | -------------------------------------------------------------------------------- /keymaps/php-ide-serenata.cson: -------------------------------------------------------------------------------- 1 | # Keybindings require three things to be fully defined: A selector that is 2 | # matched against the focused element, the keystroke and the command to 3 | # execute. 4 | # 5 | # Below is a basic keybinding which registers on all platforms by applying to 6 | # the root workspace element. 7 | 8 | # For more detailed documentation see 9 | # https://atom.io/docs/latest/behind-atom-keymaps-in-depth 10 | 'atom-text-editor': 11 | 'alt-m': 'php-ide-serenata:extract-method' 12 | -------------------------------------------------------------------------------- /lib/AtomConfig.js: -------------------------------------------------------------------------------- 1 | /* global atom */ 2 | 3 | 'use strict'; 4 | 5 | const path = require('path'); 6 | const process = require('process'); 7 | const mkdirp = require('mkdirp'); 8 | 9 | const Config = require('./Config'); 10 | 11 | module.exports = 12 | 13 | class AtomConfig extends Config 14 | { 15 | /** 16 | * @inheritdoc 17 | */ 18 | constructor(packageName) { 19 | super(); 20 | 21 | this.packageName = packageName; 22 | this.configurableProperties = [ 23 | 'core.phpExecutionType', 24 | 'core.phpCommand', 25 | 'core.memoryLimit', 26 | 'core.additionalDockerVolumes', 27 | 'general.doNotAskForSupport', 28 | 'general.doNotShowProjectChangeMessage', 29 | 'general.projectOpenCount', 30 | 'refactoring.enable', 31 | ]; 32 | 33 | this.attachListeners(); 34 | } 35 | 36 | /** 37 | * @inheritdoc 38 | */ 39 | load() { 40 | this.set('storagePath', this.getPathToStorageFolderInRidiculousWay()); 41 | 42 | this.configurableProperties.forEach((property) => { 43 | this.set(property, atom.config.get(`${this.packageName}.${property}`)); 44 | }); 45 | } 46 | 47 | /** 48 | * @inheritdoc 49 | */ 50 | set(name, value) { 51 | super.set(name, value); 52 | 53 | atom.config.set(`${this.packageName}.${name}`, value); 54 | } 55 | 56 | /** 57 | * Attaches listeners to listen to Atom configuration changes. 58 | */ 59 | attachListeners() { 60 | this.configurableProperties.forEach((property) => { 61 | atom.config.onDidChange(`${this.packageName}.${property}`, (data) => { 62 | this.set(property, data.newValue); 63 | }); 64 | }); 65 | } 66 | 67 | /** 68 | * @return {String} 69 | */ 70 | getPathToStorageFolderInRidiculousWay() { 71 | // NOTE: Apparently process.env.ATOM_HOME is not always set for whatever reason and this ridiculous workaround 72 | // is needed to fetch an OS-compliant location to store application data. 73 | let baseFolder = null; 74 | 75 | if (process.env.APPDATA) { 76 | baseFolder = process.env.APPDATA; 77 | } else if (process.platform === 'darwin') { 78 | baseFolder = process.env.HOME + '/Library/Preferences'; 79 | } else { 80 | baseFolder = process.env.HOME + '/.cache'; 81 | } 82 | 83 | const packageFolder = baseFolder + path.sep + this.packageName; 84 | 85 | mkdirp.sync(packageFolder); 86 | 87 | return packageFolder; 88 | } 89 | }; 90 | -------------------------------------------------------------------------------- /lib/CancellablePromise.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = 4 | 5 | /** 6 | * Promise that can be cancelled. 7 | */ 8 | class CancellablePromise //extends Promise 9 | { 10 | /** 11 | * Constructor. 12 | * 13 | * @param {Callable} executor 14 | * @param {Callable} cancelHandler 15 | */ 16 | constructor(executor, cancelHandler) 17 | { 18 | this.isDone = false; 19 | this.promise = new Promise(executor); 20 | 21 | if (cancelHandler) { 22 | this.cancelHandler = cancelHandler; 23 | } else { 24 | this.cancelHandler = () => { 25 | // this.promise.reject('Promise cancelled'); 26 | }; 27 | } 28 | } 29 | 30 | /** 31 | * Cancels the promise. 32 | */ 33 | cancel() 34 | { 35 | if (this.isDone !== true) { 36 | this.cancelHandler.call(this); 37 | } 38 | } 39 | 40 | /** 41 | * @param {callable} onFulfilled 42 | * @param {callable} onRejected 43 | * 44 | * @return {Promise} 45 | */ 46 | then(onFulfilled, onRejected = undefined) 47 | { 48 | return this.promise.then(onFulfilled, onRejected); 49 | } 50 | 51 | /** 52 | * @param {callable} onRejected 53 | * 54 | * @return {Promise} 55 | */ 56 | catch(onRejected) 57 | { 58 | return this.promise.catch(onRejected); 59 | } 60 | 61 | /** 62 | * @param {*} value 63 | * 64 | * @return {Promise} 65 | */ 66 | resolve(value) 67 | { 68 | this.isDone = true; 69 | 70 | return this.promise.resolve(value); 71 | } 72 | 73 | /** 74 | * @param {*} reason 75 | * 76 | * @return {Promise} 77 | */ 78 | reject(reason) 79 | { 80 | this.isDone = true; 81 | 82 | return this.promise.reject(reason); 83 | } 84 | }; 85 | -------------------------------------------------------------------------------- /lib/CodeLensManager.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = 4 | 5 | /** 6 | * Manages visual information around code lenses returned by the server. 7 | */ 8 | class CodeLensManager 9 | { 10 | /** 11 | * Constructor. 12 | */ 13 | constructor() { 14 | this.markers = {}; 15 | this.annotations = {}; 16 | } 17 | 18 | /** 19 | * @param {TextEditor} editor 20 | * @param {Array} codeLenses 21 | * @param {Callable} executeCommandHandler 22 | */ 23 | process(editor, codeLenses, executeCommandHandler) { 24 | this.removeMarkers(editor); 25 | 26 | const {Convert} = require('atom-languageclient'); 27 | 28 | // You cannot have multiple markers on the exact same line, you need to combine them into one element and 29 | // have one marker, so do some grouping up front. 30 | const codeLensesGroupedByLine = codeLenses.reduce((accumulator, codeLens) => { 31 | const range = Convert.lsRangeToAtomRange(codeLens.range); 32 | 33 | if (!accumulator.has(range.start.row)) { 34 | accumulator.set(range.start.row, new Array()); 35 | } 36 | 37 | accumulator.get(range.start.row).push(codeLens); 38 | 39 | return accumulator; 40 | }, new Map()); 41 | 42 | codeLensesGroupedByLine.forEach((codeLenses, line) => { 43 | this.processForLine(editor, codeLenses, line, executeCommandHandler); 44 | }); 45 | } 46 | 47 | /** 48 | * @param {TextEditor} editor 49 | * @param {Array} codeLenses 50 | * @param {Number} line 51 | * @param {Callable} executeCommandHandler 52 | */ 53 | processForLine(editor, codeLenses, line, executeCommandHandler) { 54 | const {Range, Point} = require('atom'); 55 | const {Convert} = require('atom-languageclient'); 56 | 57 | const marker = this.registerMarker(editor, new Range(new Point(line, 0), new Point(line, 1)), { 58 | invalidate : 'touch', 59 | }); 60 | 61 | const codeLensLineElement = document.createElement('div'); 62 | codeLensLineElement.classList.add('php-ide-serenata-code-lens-wrapper'); 63 | 64 | let charactersTaken = 0; 65 | 66 | codeLenses.forEach((codeLens) => { 67 | if (!codeLens.command) { 68 | // To support this, one would have to send a resolve request and show some sort of placeholder 69 | // beforehand, as we wouldn't know what title to show yet. 70 | throw new Error('Code lenses with unresolved commands are currently not supported'); 71 | } 72 | 73 | const range = Convert.lsRangeToAtomRange(codeLens.range); 74 | const paddingSpacesNeeded = range.start.column - charactersTaken; 75 | 76 | // Having one marker per line (see above) means that we need to do padding ourselves when multiple code 77 | // lenses are present. This can happen in cases where multiple properties are on one line, and more than one 78 | // of them is an override. ot great, but it gets the job done. 79 | const paddingSpanElement = document.createElement('span'); 80 | paddingSpanElement.innerHTML = ' '.repeat(paddingSpacesNeeded); 81 | 82 | const anchorElement = document.createElement('a'); 83 | anchorElement.innerHTML = codeLens.command.title; 84 | anchorElement.classList.add('badge'); 85 | anchorElement.classList.add('badge-small'); 86 | anchorElement.href = '#'; 87 | anchorElement.addEventListener('click', () => { 88 | executeCommandHandler({ 89 | command: codeLens.command.command, 90 | arguments: codeLens.command.arguments, 91 | }); 92 | }); 93 | 94 | charactersTaken += paddingSpacesNeeded + anchorElement.innerHTML.length ; 95 | 96 | const wrapperElement = document.createElement('div'); 97 | wrapperElement.classList.add('php-ide-serenata-code-lens'); 98 | // wrapperElement.style.marginLeft = range.start.column + 'em'; 99 | wrapperElement.appendChild(paddingSpanElement); 100 | wrapperElement.appendChild(anchorElement); 101 | 102 | codeLensLineElement.appendChild(wrapperElement); 103 | }); 104 | 105 | editor.decorateMarker(marker, { 106 | type: 'block', 107 | item: codeLensLineElement, 108 | }); 109 | } 110 | 111 | /** 112 | * @param {TextEditor} editor 113 | * @param {Range} range 114 | * @param {Object} options 115 | * 116 | * @return {Object} 117 | */ 118 | registerMarker(editor, range, options) { 119 | const marker = editor.markBufferRange(range, options); 120 | 121 | if (!(editor.id in this.markers)) { 122 | this.markers[editor.id] = []; 123 | } 124 | 125 | this.markers[editor.id].push(marker); 126 | 127 | return marker; 128 | } 129 | 130 | /** 131 | * @param {TextEditor} editor 132 | */ 133 | removeMarkers(editor) { 134 | for (let i in this.markers[editor.id]) { 135 | const marker = this.markers[editor.id][i]; 136 | marker.destroy(); 137 | } 138 | 139 | this.markers[editor.id] = []; 140 | } 141 | }; 142 | -------------------------------------------------------------------------------- /lib/Config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = 4 | 5 | /** 6 | * Abstract base class for managing configurations. 7 | */ 8 | class Config { 9 | constructor() { 10 | this.listeners = {}; 11 | 12 | this.data = { 13 | 'core.phpExecutionType' : 'host', 14 | 'core.phpCommand' : null, 15 | 'core.memoryLimit' : 2048, 16 | 'core.additionalDockerVolumes' : [], 17 | 18 | 'general.doNotAskForSupport' : false, 19 | 'general.doNotShowProjectChangeMessage' : false, 20 | 'general.projectOpenCount' : 0, 21 | 22 | 'refactoring.enable' : true, 23 | }; 24 | } 25 | 26 | load() { 27 | throw new Error('This method is abstract and must be implemented!'); 28 | } 29 | 30 | onDidChange(name, callback) { 31 | if (!(name in this.listeners)) { 32 | this.listeners[name] = []; 33 | } 34 | 35 | this.listeners[name].push(callback); 36 | } 37 | 38 | get(name) { 39 | return this.data[name]; 40 | } 41 | 42 | set(name, value) { 43 | this.data[name] = value; 44 | 45 | if (name in this.listeners) { 46 | this.listeners[name].map((listener) => { 47 | listener(value, name); 48 | }); 49 | } 50 | } 51 | }; 52 | -------------------------------------------------------------------------------- /lib/ConfigTester.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = 4 | 5 | /** 6 | * Tests the user's PHP setup to see if it is properly usable. 7 | */ 8 | class ConfigTester 9 | { 10 | /** 11 | * Constructor. 12 | * 13 | * @param {PhpInvoker} phpInvoker 14 | */ 15 | constructor(phpInvoker) { 16 | this.phpInvoker = phpInvoker; 17 | } 18 | 19 | /** 20 | * @return {Promise} 21 | */ 22 | test() { 23 | return new Promise((resolve) => { 24 | const process = this.phpInvoker.invoke(['-v']); 25 | 26 | process.on('close', (code) => { 27 | if (code === 0) { 28 | resolve(true); 29 | return; 30 | } 31 | 32 | resolve(false); 33 | }); 34 | }); 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /lib/Main.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const ServiceContainer = require('./ServiceContainer'); 4 | 5 | const container = new ServiceContainer(); 6 | 7 | module.exports = container.getSerenataClient(); 8 | -------------------------------------------------------------------------------- /lib/PhpInvoker.js: -------------------------------------------------------------------------------- 1 | /* global atom */ 2 | 3 | 'use strict'; 4 | 5 | module.exports = 6 | 7 | /** 8 | * Invokes PHP. 9 | */ 10 | class PhpInvoker 11 | { 12 | /** 13 | * Constructor. 14 | * 15 | * @param {Config} config 16 | */ 17 | constructor(config) { 18 | this.config = config; 19 | } 20 | 21 | /** 22 | * Invokes PHP. 23 | * 24 | * NOTE: The composer:1.6.4 image uses the Alpine version of the "PHP 7.x" image of PHP, which at the time of 25 | * writing is PHP 7.2. The most important part is that the PHP version used for Composer installations is the same 26 | * as the one used for actually running the server to avoid outdated or too recent dependencies. 27 | * 28 | * @param {Array} parameters 29 | * @param {Array} additionalDockerRunParameters 30 | * @param {Object} options 31 | * @param {String} dockerImage 32 | * 33 | * @return {Process} 34 | */ 35 | invoke(parameters, additionalDockerRunParameters = [], options = {}, dockerImage = 'composer:1.6.4') { 36 | const child_process = require('child_process'); 37 | const executionType = this.config.get('core.phpExecutionType'); 38 | 39 | if (executionType === 'host') { 40 | return child_process.spawn(this.config.get('core.phpCommand'), parameters, options); 41 | } 42 | 43 | let command = 'docker'; 44 | 45 | if (executionType === 'podman') { 46 | additionalDockerRunParameters = ['-net=host'].concat(additionalDockerRunParameters); 47 | } 48 | 49 | let dockerParameters = this.getDockerRunParameters(dockerImage, additionalDockerRunParameters); 50 | 51 | dockerParameters = dockerParameters.concat(parameters); 52 | 53 | if (executionType === 'docker') { 54 | command = 'docker'; 55 | } else if (executionType === 'docker-polkit') { 56 | dockerParameters = [command].concat(dockerParameters); 57 | command = 'pkexec'; 58 | } else if (executionType === 'podman') { 59 | /* 60 | Add this to the PHP execution type config option in package.json to test this: 61 | 62 | { 63 | "value": "podman", 64 | "description": "Use a PHP container via Podman, avoiding privilege escalation entirely (experimental)" 65 | } 66 | 67 | Note that podman doesn't work yet because it doesn't support rootless port binding yet at the time of 68 | writing. I don't believe Serenata needs any special ports below 1024 or any other things that requires 69 | root, so if they implement support for this, we should be able to easily support it. 70 | */ 71 | command = 'podman'; 72 | } else { 73 | throw new Error('Unknown executionType "' + executionType + '" received'); 74 | } 75 | 76 | const process = child_process.spawn(command, dockerParameters); 77 | 78 | console.debug(command, dockerParameters); 79 | 80 | // NOTE: Uncomment this to test failures 81 | process.stdout.on('data', data => { 82 | console.debug('STDOUT', data.toString()); 83 | }); 84 | 85 | process.stderr.on('data', data => { 86 | console.debug('STDERR', data.toString()); 87 | }); 88 | 89 | return process; 90 | } 91 | 92 | /** 93 | * @param {Array} additionalDockerRunParameters 94 | * 95 | * @return {Array} 96 | */ 97 | getDockerRunParameters(dockerImage, additionalDockerRunParameters) { 98 | const parameters = ['run', '--rm=true']; 99 | const object = this.getPathsToMountInDockerContainer(); 100 | 101 | for (const src in object) { 102 | const dest = object[src]; 103 | parameters.push('-v'); 104 | parameters.push(src + ':' + dest); 105 | } 106 | 107 | return parameters.concat(additionalDockerRunParameters).concat([dockerImage, 'php']); 108 | } 109 | 110 | /** 111 | * @return {Object} 112 | */ 113 | getPathsToMountInDockerContainer() { 114 | const paths = {}; 115 | paths[this.config.get('storagePath')] = this.config.get('storagePath'); 116 | 117 | for (const path of this.config.get('core.additionalDockerVolumes')) { 118 | const parts = path.split(':'); 119 | 120 | paths[parts[0]] = parts[1]; 121 | } 122 | 123 | for (const path of atom.project.getPaths()) { 124 | paths[path] = path; 125 | } 126 | 127 | return this.normalizeVolumePaths(paths); 128 | } 129 | 130 | /** 131 | * @param {Object} pathMap 132 | * 133 | * @return {Object} 134 | */ 135 | normalizeVolumePaths(pathMap) { 136 | const newPathMap = {}; 137 | 138 | for (let src in pathMap) { 139 | const dest = pathMap[src]; 140 | newPathMap[this.normalizeVolumePath(src)] = this.normalizeVolumePath(dest); 141 | } 142 | 143 | return newPathMap; 144 | } 145 | 146 | /** 147 | * @param {String} path 148 | * 149 | * @return {String} 150 | */ 151 | normalizeVolumePath(path) { 152 | const os = require('os'); 153 | 154 | if (os.platform() !== 'win32') { 155 | return path; 156 | } 157 | 158 | const matches = path.match(/^([A-Z]+):(.+)$/); 159 | 160 | if (matches !== null) { 161 | // On Windows, paths for Docker volumes are specified as /c/Path/To/Item. 162 | path = `/${matches[1].toLowerCase()}${matches[2]}`; 163 | } 164 | 165 | return path.replace(/\\/g, '/'); 166 | } 167 | 168 | /** 169 | * @param {String} path 170 | * 171 | * @return {String} 172 | */ 173 | denormalizeVolumePath(path) { 174 | const os = require('os'); 175 | 176 | if (os.platform() !== 'win32') { 177 | return path; 178 | } 179 | 180 | const matches = path.match(/^\/([a-z]+)\/(.+)$/); 181 | 182 | if (matches !== null) { 183 | // On Windows, paths for Docker volumes are specified as /c/Path/To/Item. 184 | path = matches[1].toUpperCase() + ':\\' + matches[2]; 185 | } 186 | 187 | return path.replace(/\//g, '\\'); 188 | } 189 | 190 | /** 191 | * Retrieves a path normalized for the current platform *and* runtime. 192 | * 193 | * On Windows, we still need UNIX paths if we are using Docker as runtime, 194 | * but not if we are using the host PHP. 195 | * 196 | * @param {String} path 197 | * 198 | * @return {String} 199 | */ 200 | normalizePlatformAndRuntimePath(path) { 201 | if (this.config.get('core.phpExecutionType') === 'host') { 202 | return path; 203 | } 204 | 205 | return this.normalizeVolumePath(path); 206 | } 207 | 208 | /** 209 | * Retrieves a path denormalized for the current platform *and* runtime. 210 | * 211 | * On Windows, this converts Docker paths back to Windows paths. 212 | * 213 | * @param {String} path 214 | * 215 | * @return {String} 216 | */ 217 | denormalizePlatformAndRuntimePath(path) { 218 | if (this.config.get('core.phpExecutionType') === 'host') { 219 | return path; 220 | } 221 | 222 | return this.denormalizeVolumePath(path); 223 | } 224 | }; 225 | -------------------------------------------------------------------------------- /lib/ProjectManager.js: -------------------------------------------------------------------------------- 1 | /* global atom */ 2 | 3 | 'use strict'; 4 | 5 | const fs = require('fs'); 6 | const mkdirp = require('mkdirp'); 7 | 8 | module.exports = 9 | 10 | class ProjectManager 11 | { 12 | /** 13 | * @param {Object} proxy 14 | */ 15 | constructor(proxy, config) { 16 | this.proxy = proxy; 17 | this.config = config; 18 | this.activeProject = null; 19 | } 20 | 21 | /** 22 | * Sets up the specified project for usage with the Serenata server. 23 | * 24 | * @param {String} mainFolder 25 | */ 26 | setUpProject(mainFolder) { 27 | // const process = require('process'); 28 | // 29 | // // Great, URI's are supposed to be file:// + host (optional) + path, which on UNIX systems becomes something 30 | // // like file:///my/folder if the host is omitted. Microsoft decided to do it differently and does 31 | // // file:///c:/my/path instead of file://C:/my/path, so add an extra slash and lower case the drive letter. 32 | // if (process.platform === 'win32') { 33 | // mainFolder = '/' + mainFolder.substr(0, 1).toLowerCase() + mainFolder.substr(1); 34 | // } 35 | 36 | const path = require('path'); 37 | const crypto = require('crypto'); 38 | 39 | const md5 = crypto.createHash('md5'); 40 | const configFileFolderPath = mainFolder + '/.serenata'; 41 | const configFilePath = configFileFolderPath + '/config.json'; 42 | 43 | // NOTE: I wanted to place the index inside the .serenata folder, but it turns out that is a very bad idea. 44 | // Pulsar will start firing massive amounts of change requests, due to it watching the database file, every time 45 | // it is modified. We can disable this for our package, but not for other packages, which will still receive 46 | // these events en masse uselessly, which not only prevents any other responses from being handled in the 47 | // meantime, it also spikes CPU usage. 48 | const indexDatabaseUri = 'file://' + path.join( 49 | this.config.get('storagePath'), 50 | 'index-' + md5.update(mainFolder).digest('hex') + '.sqlite' 51 | ); 52 | 53 | if (fs.existsSync(configFilePath)) { 54 | throw new Error( 55 | 'The currently active project was already initialized. To prevent existing settings from being ' + 56 | 'lost, the request has been aborted.' 57 | ); 58 | } 59 | 60 | const template = 61 | `{ 62 | "uris": [ 63 | "file://${mainFolder.replace(/\\/g, '\\\\')}" 64 | ], 65 | "indexDatabaseUri": "${indexDatabaseUri.replace(/\\/g, '\\\\')}", 66 | "phpVersion": 7.3, 67 | "excludedPathExpressions": [], 68 | "fileExtensions": [ 69 | "php" 70 | ] 71 | }`; 72 | 73 | mkdirp.sync(configFileFolderPath); 74 | fs.writeFileSync(configFilePath, template); 75 | } 76 | 77 | /** 78 | * @param {String} projectFolder 79 | * 80 | * @return {Boolean} 81 | */ 82 | shouldStartForProject(projectFolder) { 83 | return fs.existsSync(this.getConfigFilePath(projectFolder)); 84 | } 85 | 86 | /** 87 | * @param {String} projectFolder 88 | */ 89 | tryLoad(projectFolder) { 90 | if (!this.shouldStartForProject(projectFolder)) { 91 | return; 92 | } 93 | 94 | this.load(projectFolder); 95 | } 96 | 97 | /** 98 | * @param {String} projectFolder 99 | */ 100 | load(projectFolder) { 101 | const path = this.getConfigFilePath(projectFolder); 102 | 103 | try { 104 | this.activeProject = JSON.parse(fs.readFileSync(path)); 105 | } catch (e) { 106 | const message = 107 | 'Loading project configuration in **' + path + '** failed. It likely contains syntax errors. \n \n' + 108 | 109 | 'The error message returned was:\n \n' + 110 | 111 | '```' + e + '```'; 112 | 113 | atom.notifications.addError('Serenata - Loading Project Failed', { 114 | description: message, 115 | dismissable: true 116 | }); 117 | 118 | throw new Error('Loading project at "' + path + '" failed due to it not being valid JSON'); 119 | } 120 | } 121 | 122 | /** 123 | * @param {String} projectFolder 124 | * 125 | * @return {String} 126 | */ 127 | getConfigFilePath(projectFolder) { 128 | return projectFolder + '/.serenata/config.json'; 129 | } 130 | 131 | /** 132 | * @return {Object|null} 133 | */ 134 | getCurrentProjectSettings() { 135 | return this.activeProject; 136 | } 137 | }; 138 | -------------------------------------------------------------------------------- /lib/Proxy.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const net = require('net'); 4 | 5 | module.exports = 6 | 7 | class Proxy { 8 | /** 9 | * Constructor. 10 | * 11 | * @param {Config} config 12 | * @param {PhpInvoker} phpInvoker 13 | */ 14 | constructor(config, phpInvoker) { 15 | this.serverPath = null; 16 | this.config = config; 17 | this.phpInvoker = phpInvoker; 18 | this.port = this.generateRandomServerPort(); 19 | } 20 | 21 | /** 22 | * Spawns the PHP socket server process. 23 | * 24 | * @param {Number} port 25 | * 26 | * @return {Promise} 27 | */ 28 | async spawnPhpServer(port) { 29 | const memoryLimit = this.config.get('core.memoryLimit'); 30 | const socketHost = this.config.get('core.phpExecutionType') === 'host' ? '127.0.0.1' : '0.0.0.0'; 31 | 32 | const parameters = [ 33 | // Enable this to debug or profile using Xdebug. You will also need to allow Xdebug below. 34 | //'-d zend_extension=/usr/lib/php/modules/xdebug.so', 35 | //'-d xdebug.profiler_enable=On', 36 | // // '-d xdebug.gc_stats_enable=On', 37 | //'-d xdebug.profiler_output_dir=/tmp', 38 | 39 | `-d memory_limit=${memoryLimit}M`, 40 | this.phpInvoker.normalizePlatformAndRuntimePath(this.serverPath) + 'distribution.phar', 41 | `--uri=tcp://${socketHost}:${port}` 42 | ]; 43 | 44 | const additionalDockerRunParameters = [ 45 | '-p', `127.0.0.1:${port}:${port}` 46 | ]; 47 | 48 | const options = { 49 | // Enable this to debug or profile as well. 50 | // env: { 51 | // SERENATA_ALLOW_XDEBUG: 1 52 | // } 53 | }; 54 | 55 | const process = this.phpInvoker.invoke(parameters, additionalDockerRunParameters, options); 56 | 57 | return new Promise((resolve, reject) => { 58 | process.stdout.on('data', data => { 59 | const message = data.toString(); 60 | 61 | console.debug('The PHP server has something to say:', message); 62 | 63 | if (message.indexOf('Starting server bound') !== -1) { 64 | // Assume the server has successfully spawned the moment it says it's listening. 65 | resolve(process); 66 | } 67 | }); 68 | 69 | process.stderr.on('data', data => { 70 | console.error('The PHP server has errors to report:', data.toString()); 71 | }); 72 | 73 | process.on('close', (code) => { 74 | if (code === 2) { 75 | console.error(`Port ${port} is already taken`); 76 | } else if (code !== 0 && code !== null) { 77 | const detail = 78 | 'Serenata unexpectedly closed. Either something caused the process to stop, it crashed, ' + 79 | 'or the socket closed. In case of the first two, you should see additional output ' + 80 | 'indicating this is the case and you can report a bug. If there is no additional output, ' + 81 | 'you may be missing the right dependencies or extensions or the server may have run out ' + 82 | 'of memory (you can increase it via the settings screen).'; 83 | 84 | console.error(detail); 85 | } 86 | 87 | reject(); 88 | }); 89 | }); 90 | } 91 | 92 | /** 93 | * @return {Number} 94 | */ 95 | generateRandomServerPort() { 96 | const minPort = 10000; 97 | const maxPort = 40000; 98 | 99 | return Math.floor((Math.random() * (maxPort - minPort)) + minPort); 100 | } 101 | 102 | /** 103 | * @return {Promise} 104 | */ 105 | async getSocketConnection() { 106 | const phpServer = await this.spawnPhpServer(this.port); 107 | 108 | return new Promise((resolve) => { 109 | const client = net.createConnection({port: this.port}, () => { 110 | resolve([client, phpServer]); 111 | }); 112 | 113 | client.setNoDelay(true); 114 | client.on('error', this.onSocketError.bind(this)); 115 | }); 116 | } 117 | 118 | /** 119 | * @param {Object} error 120 | */ 121 | onSocketError(error) { 122 | // Do nothing here, this should silence socket errors such as ECONNRESET. After this is called, the socket 123 | // will be closed and all handling is performed there. 124 | console.debug( 125 | 'The socket connection notified us of an error (this is normal if the server is shutdown).', 126 | error 127 | ); 128 | } 129 | 130 | /** 131 | * @param {String} serverPath 132 | */ 133 | setServerPath(serverPath) { 134 | this.serverPath = serverPath; 135 | } 136 | }; 137 | -------------------------------------------------------------------------------- /lib/Refactoring/AbstractProvider.js: -------------------------------------------------------------------------------- 1 | /* global atom */ 2 | 3 | /* 4 | * decaffeinate suggestions: 5 | * DS102: Remove unnecessary code created because of implicit returns 6 | * DS207: Consider shorter variations of null checks 7 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 8 | */ 9 | module.exports = 10 | 11 | /** 12 | * Base class for providers. 13 | */ 14 | class AbstractProvider { 15 | /** 16 | * Constructor. 17 | */ 18 | constructor() { 19 | /** 20 | * The service (that can be used to query the source code and contains utility methods). 21 | */ 22 | this.service = null; 23 | 24 | /** 25 | * Service to insert snippets into the editor. 26 | */ 27 | this.snippetManager = null; 28 | } 29 | 30 | /** 31 | * Initializes this provider. 32 | * 33 | * @param {mixed} service 34 | */ 35 | activate(service) { 36 | this.service = service; 37 | const dependentPackage = 'language-php'; 38 | 39 | // It could be that the dependent package is already active, in that case we can continue immediately. 40 | // If not, we'll need to wait for the listener to be invoked 41 | if (atom.packages.isPackageActive(dependentPackage)) { 42 | this.doActualInitialization(); 43 | } 44 | 45 | atom.packages.onDidActivatePackage(packageData => { 46 | if (packageData.name !== dependentPackage) { return; } 47 | 48 | return this.doActualInitialization(); 49 | }); 50 | 51 | return atom.packages.onDidDeactivatePackage(packageData => { 52 | if (packageData.name !== dependentPackage) { return; } 53 | 54 | return this.deactivate(); 55 | }); 56 | } 57 | 58 | /** 59 | * Does the actual initialization. 60 | */ 61 | doActualInitialization() { 62 | atom.workspace.observeTextEditors(editor => { 63 | if (/text.html.php$/.test(editor.getGrammar().scopeName)) { 64 | return this.registerEvents(editor); 65 | } 66 | }); 67 | 68 | // When you go back to only have one pane the events are lost, so need to re-register. 69 | atom.workspace.onDidDestroyPane(() => { 70 | const panes = atom.workspace.getPanes(); 71 | 72 | if (panes.length === 1) { 73 | return this.registerEventsForPane(panes[0]); 74 | } 75 | }); 76 | 77 | // Having to re-register events as when a new pane is created the old panes lose the events. 78 | return atom.workspace.onDidAddPane(observedPane => { 79 | const panes = atom.workspace.getPanes(); 80 | 81 | const result = []; 82 | 83 | for (const pane of panes) { 84 | if (pane !== observedPane) { 85 | result.push(this.registerEventsForPane(pane)); 86 | } else { 87 | result.push(undefined); 88 | } 89 | } 90 | 91 | return result; 92 | }); 93 | } 94 | 95 | /** 96 | * Registers the necessary event handlers for the editors in the specified pane. 97 | * 98 | * @param {Pane} pane 99 | */ 100 | registerEventsForPane(pane) { 101 | const result = []; 102 | 103 | for (const paneItem of pane.items) { 104 | if (atom.workspace.isTextEditor(paneItem)) { 105 | if (/text.html.php$/.test(paneItem.getGrammar().scopeName)) { 106 | result.push(this.registerEvents(paneItem)); 107 | } else { 108 | result.push(undefined); 109 | } 110 | } else { 111 | result.push(undefined); 112 | } 113 | } 114 | 115 | return result; 116 | } 117 | 118 | /** 119 | * Deactives the provider. 120 | */ 121 | deactivate() {} 122 | 123 | /** 124 | * Retrieves intention providers (by default, the intentions menu shows when the user presses alt-enter). 125 | * 126 | * This method should be overwritten by subclasses. 127 | * 128 | * @return {array} 129 | */ 130 | getIntentionProviders() { 131 | return []; 132 | } 133 | 134 | /** 135 | * Registers the necessary event handlers. 136 | * 137 | * @param {TextEditor} _ TextEditor to register events to. 138 | */ 139 | // eslint-disable-next-line no-unused-vars 140 | registerEvents(_) {} 141 | 142 | /** 143 | * Sets the snippet manager 144 | * 145 | * @param {Object} @snippetManager 146 | */ 147 | setSnippetManager(snippetManager) { 148 | this.snippetManager = snippetManager; 149 | } 150 | 151 | /** 152 | * @return {Number|null} 153 | */ 154 | getCurrentProjectPhpVersion() { 155 | const projectSettings = this.service.getCurrentProjectSettings(); 156 | 157 | if (projectSettings != null) { 158 | return projectSettings.phpVersion; 159 | } 160 | 161 | return null; 162 | } 163 | 164 | /** 165 | * @param {Array} functionParameters 166 | * @param {String} editorUri 167 | * @param {Position} bufferPosition 168 | * 169 | * @return {Array} 170 | */ 171 | async localizeFunctionParameterTypeHints(functionParameters, editorUri, bufferPosition) { 172 | await Promise.all(functionParameters.map(async (parameter) => { 173 | if (!parameter.typeHint) { 174 | return parameter.typeHint; 175 | } 176 | 177 | parameter.typeHint = await this.service.localizeType( 178 | editorUri, 179 | bufferPosition, 180 | parameter.typeHint, 181 | 'classlike' 182 | ); 183 | 184 | return parameter; 185 | })); 186 | } 187 | 188 | /** 189 | * @param {Array} functionParameters 190 | * @param {String} editorUri 191 | * @param {Position} bufferPosition 192 | * 193 | * @return {Array} 194 | */ 195 | async localizeFunctionParametersTypes(functionParameters, editorUri, bufferPosition) { 196 | await Promise.all(functionParameters.map(async (parameter) => { 197 | await this.localizeFunctionParameterTypes(parameter, editorUri, bufferPosition); 198 | })); 199 | } 200 | 201 | /** 202 | * @param {Object} functionParameter 203 | * @param {String} editorUri 204 | * @param {Position} bufferPosition 205 | * 206 | * @return {Array} 207 | */ 208 | async localizeFunctionParameterTypes(functionParameter, editorUri, bufferPosition, property = 'types') { 209 | await Promise.all(functionParameter[property].map(async (type) => { 210 | if (!type.type) { 211 | return type.type; 212 | } 213 | 214 | type.type = await this.service.localizeType( 215 | editorUri, 216 | bufferPosition, 217 | type.type, 218 | 'classlike' 219 | ); 220 | 221 | return type; 222 | })); 223 | } 224 | }; 225 | -------------------------------------------------------------------------------- /lib/Refactoring/ConstructorGenerationProvider.js: -------------------------------------------------------------------------------- 1 | /* global atom */ 2 | 3 | /* 4 | * decaffeinate suggestions: 5 | * DS102: Remove unnecessary code created because of implicit returns 6 | * DS207: Consider shorter variations of null checks 7 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 8 | */ 9 | const AbstractProvider = require('./AbstractProvider'); 10 | 11 | module.exports = 12 | 13 | /** 14 | * Provides docblock generation and maintenance capabilities. 15 | */ 16 | class ConstructorGenerationProvider extends AbstractProvider { 17 | /** 18 | * @param {Object} typeHelper 19 | * @param {Object} functionBuilder 20 | * @param {Object} docblockBuilder 21 | */ 22 | constructor(typeHelper, functionBuilder, docblockBuilder) { 23 | super(); 24 | 25 | /** 26 | * The view that allows the user to select the properties to add to the constructor as parameters. 27 | */ 28 | this.selectionView = null; 29 | 30 | /** 31 | * Aids in building functions. 32 | */ 33 | this.functionBuilder = functionBuilder; 34 | 35 | /** 36 | * The docblock builder. 37 | */ 38 | this.docblockBuilder = docblockBuilder; 39 | 40 | /** 41 | * The type helper. 42 | */ 43 | this.typeHelper = typeHelper; 44 | } 45 | 46 | /** 47 | * @inheritdoc 48 | */ 49 | deactivate() { 50 | super.deactivate(); 51 | 52 | if (this.selectionView) { 53 | this.selectionView.destroy(); 54 | this.selectionView = null; 55 | } 56 | } 57 | 58 | /** 59 | * @inheritdoc 60 | */ 61 | getIntentionProviders() { 62 | return [{ 63 | grammarScopes: ['source.php'], 64 | getIntentions: ({textEditor, bufferPosition}) => { 65 | if ((this.getCurrentProjectPhpVersion() == null)) { return []; } 66 | 67 | return this.getIntentions(textEditor, bufferPosition); 68 | } 69 | }]; 70 | } 71 | 72 | /** 73 | * @param {TextEditor} editor 74 | * @param {Point} triggerPosition 75 | */ 76 | getIntentions(editor, triggerPosition) { 77 | const successHandler = currentClassName => { 78 | if ((currentClassName == null)) { return []; } 79 | 80 | const nestedSuccessHandler = classInfo => { 81 | if ((classInfo == null)) { return []; } 82 | 83 | return [{ 84 | priority : 100, 85 | icon : 'gear', 86 | title : 'Generate Constructor', 87 | 88 | selected : () => { 89 | const items = []; 90 | const promises = []; 91 | 92 | const localTypesResolvedHandler = results => { 93 | let resultIndex = 0; 94 | 95 | for (const item of items) { 96 | for (const type of item.types) { 97 | type.type = results[resultIndex++]; 98 | } 99 | } 100 | 101 | const tabText = editor.getTabText(); 102 | const indentationLevel = editor.indentationForBufferRow(triggerPosition.row); 103 | 104 | return this.generateConstructor( 105 | editor, 106 | triggerPosition, 107 | items, 108 | tabText, 109 | indentationLevel, 110 | atom.config.get( 111 | 'editor.preferredLineLength', 112 | editor.getLastCursor().getScopeDescriptor() 113 | ) 114 | ); 115 | }; 116 | 117 | if (classInfo.properties.length === 0) { 118 | return localTypesResolvedHandler([]); 119 | 120 | } else { 121 | // Ensure all types are localized to the use statements of this file, the original 122 | // types will be relative to the original file (which may not be the same). 123 | // The FQCN works but is long and there may be a local use statement that can be used 124 | // to shorten it. 125 | for (let name in classInfo.properties) { 126 | const property = classInfo.properties[name]; 127 | items.push({ 128 | name, 129 | types : property.types 130 | }); 131 | 132 | for (const type of property.types) { 133 | if (this.typeHelper.isClassType(type.type)) { 134 | promises.push(this.service.localizeType( 135 | 'file://' + editor.getPath(), 136 | triggerPosition, 137 | type.type, 138 | 'classlike' 139 | ) 140 | ); 141 | 142 | } else { 143 | promises.push(Promise.resolve(type.type)); 144 | } 145 | } 146 | } 147 | 148 | return Promise.all(promises).then(localTypesResolvedHandler, failureHandler); 149 | } 150 | } 151 | }]; 152 | }; 153 | 154 | return this.service.getClassInfo(currentClassName).then(nestedSuccessHandler, failureHandler); 155 | }; 156 | 157 | var failureHandler = () => []; 158 | 159 | return this.service.determineCurrentClassName(editor, triggerPosition).then(successHandler, failureHandler); 160 | } 161 | 162 | /** 163 | * @param {TextEditor} editor 164 | * @param {Point} triggerPosition 165 | * @param {Array} items 166 | * @param {String} tabText 167 | * @param {Number} indentationLevel 168 | * @param {Number} maxLineLength 169 | */ 170 | generateConstructor(editor, triggerPosition, items, tabText, indentationLevel, maxLineLength) { 171 | const metadata = { 172 | editor, 173 | position: triggerPosition, 174 | tabText, 175 | indentationLevel, 176 | maxLineLength 177 | }; 178 | 179 | if (items.length > 0) { 180 | this.getSelectionView().setItems(items); 181 | this.getSelectionView().setMetadata(metadata); 182 | this.getSelectionView().storeFocusedElement(); 183 | return this.getSelectionView().present(); 184 | 185 | } else { 186 | return this.onConfirm([], metadata); 187 | } 188 | } 189 | 190 | /** 191 | * Called when the selection of properties is cancelled. 192 | */ 193 | onCancel() {} 194 | 195 | /** 196 | * Called when the selection of properties is confirmed. 197 | * 198 | * @param {Array} selectedItems 199 | * @param {Object|null} metadata 200 | */ 201 | onConfirm(selectedItems, metadata) { 202 | const statements = []; 203 | const parameters = []; 204 | const docblockParameters = []; 205 | 206 | for (const item of selectedItems) { 207 | const typeSpecification = this.typeHelper.buildTypeSpecificationFromTypeArray(item.types); 208 | const parameterTypeHint = this.typeHelper.getTypeHintForTypeSpecification(typeSpecification); 209 | 210 | parameters.push({ 211 | name : `$${item.name}`, 212 | typeHint : parameterTypeHint ? parameterTypeHint.typeHint : null, 213 | defaultValue : parameterTypeHint ? 214 | (parameterTypeHint.shouldSetDefaultValueToNull ? 'null' : null) : 215 | null 216 | }); 217 | 218 | docblockParameters.push({ 219 | name : `$${item.name}`, 220 | type : item.types.length > 0 ? typeSpecification : 'mixed' 221 | }); 222 | 223 | statements.push(`$this->${item.name} = $${item.name};`); 224 | } 225 | 226 | if (statements.length === 0) { 227 | statements.push(''); 228 | } 229 | 230 | const functionText = this.functionBuilder 231 | .makePublic() 232 | .setIsStatic(false) 233 | .setIsAbstract(false) 234 | .setName('__construct') 235 | .setReturnType(null) 236 | .setParameters(parameters) 237 | .setStatements(statements) 238 | .setTabText(metadata.tabText) 239 | .setIndentationLevel(metadata.indentationLevel) 240 | .setMaxLineLength(metadata.maxLineLength) 241 | .build(); 242 | 243 | const docblockText = this.docblockBuilder.buildForMethod( 244 | docblockParameters, 245 | null, 246 | false, 247 | metadata.tabText.repeat(metadata.indentationLevel) 248 | ); 249 | 250 | const text = docblockText.trimLeft() + functionText; 251 | 252 | return metadata.editor.getBuffer().insert(metadata.position, text); 253 | } 254 | 255 | /** 256 | * @return {Builder} 257 | */ 258 | getSelectionView() { 259 | if ((this.selectionView == null)) { 260 | const View = require('./ConstructorGenerationProvider/View'); 261 | 262 | this.selectionView = new View(this.onConfirm.bind(this), this.onCancel.bind(this)); 263 | this.selectionView.setLoading('Loading class information...'); 264 | this.selectionView.setEmptyMessage('No properties found.'); 265 | } 266 | 267 | return this.selectionView; 268 | } 269 | }; 270 | -------------------------------------------------------------------------------- /lib/Refactoring/ConstructorGenerationProvider/View.js: -------------------------------------------------------------------------------- 1 | const MultiSelectionView = require('../Utility/MultiSelectionView'); 2 | 3 | module.exports = 4 | 5 | class View extends MultiSelectionView {}; 6 | -------------------------------------------------------------------------------- /lib/Refactoring/DocblockProvider.js: -------------------------------------------------------------------------------- 1 | /* 2 | * decaffeinate suggestions: 3 | * DS102: Remove unnecessary code created because of implicit returns 4 | * DS207: Consider shorter variations of null checks 5 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 6 | */ 7 | const {Point} = require('atom'); 8 | 9 | const AbstractProvider = require('./AbstractProvider'); 10 | 11 | module.exports = 12 | 13 | //#* 14 | // Provides docblock generation and maintenance capabilities. 15 | //# 16 | class DocblockProvider extends AbstractProvider { 17 | /** 18 | * @param {Object} typeHelper 19 | * @param {Object} docblockBuilder 20 | */ 21 | constructor(typeHelper, docblockBuilder) { 22 | super(); 23 | 24 | /** 25 | * The docblock builder. 26 | */ 27 | this.docblockBuilder = docblockBuilder; 28 | 29 | /** 30 | * The type helper. 31 | */ 32 | this.typeHelper = typeHelper; 33 | } 34 | 35 | /** 36 | * @inheritdoc 37 | */ 38 | getIntentionProviders() { 39 | return [{ 40 | grammarScopes: [ 41 | 'entity.name.type.class.php', 42 | 'entity.name.type.interface.php', 43 | 'entity.name.type.trait.php' 44 | ], 45 | getIntentions: ({textEditor, bufferPosition}) => { 46 | const nameRange = textEditor.bufferRangeForScopeAtCursor('entity.name.type'); 47 | 48 | if ((nameRange == null)) { return []; } 49 | if ((this.getCurrentProjectPhpVersion() == null)) { return []; } 50 | 51 | const name = textEditor.getTextInBufferRange(nameRange); 52 | 53 | return this.getClasslikeIntentions(textEditor, bufferPosition, name); 54 | } 55 | }, { 56 | grammarScopes: ['entity.name.function.php', 'support.function.magic.php'], 57 | getIntentions: ({textEditor, bufferPosition}) => { 58 | let nameRange = textEditor.bufferRangeForScopeAtCursor('entity.name.function.php'); 59 | 60 | if ((nameRange == null)) { 61 | nameRange = textEditor.bufferRangeForScopeAtCursor('support.function.magic.php'); 62 | } 63 | 64 | if ((nameRange == null)) { return []; } 65 | if ((this.getCurrentProjectPhpVersion() == null)) { return []; } 66 | 67 | const name = textEditor.getTextInBufferRange(nameRange); 68 | 69 | return this.getFunctionlikeIntentions(textEditor, bufferPosition, name); 70 | } 71 | }, { 72 | grammarScopes: ['variable.other.php'], 73 | getIntentions: ({textEditor, bufferPosition}) => { 74 | const nameRange = textEditor.bufferRangeForScopeAtCursor('variable.other.php'); 75 | 76 | if ((nameRange == null)) { return []; } 77 | if ((this.getCurrentProjectPhpVersion() == null)) { return []; } 78 | 79 | const name = textEditor.getTextInBufferRange(nameRange); 80 | 81 | return this.getPropertyIntentions(textEditor, bufferPosition, name); 82 | } 83 | }, { 84 | grammarScopes: ['constant.other.php'], 85 | getIntentions: ({textEditor, bufferPosition}) => { 86 | const nameRange = textEditor.bufferRangeForScopeAtCursor('constant.other.php'); 87 | 88 | if ((nameRange == null)) { return []; } 89 | if ((this.getCurrentProjectPhpVersion() == null)) { return []; } 90 | 91 | const name = textEditor.getTextInBufferRange(nameRange); 92 | 93 | return this.getConstantIntentions(textEditor, bufferPosition, name); 94 | } 95 | }]; 96 | } 97 | 98 | /** 99 | * @inheritdoc 100 | */ 101 | deactivate() { 102 | super.deactivate(); 103 | 104 | if (this.docblockBuilder) { 105 | //@docblockBuilder.destroy() 106 | this.docblockBuilder = null; 107 | } 108 | } 109 | 110 | /** 111 | * @param {TextEditor} editor 112 | * @param {Point} triggerPosition 113 | * @param {String} name 114 | */ 115 | getClasslikeIntentions(editor, triggerPosition, name) { 116 | const failureHandler = () => []; 117 | 118 | const successHandler = resolvedType => { 119 | const nestedSuccessHandler = classInfo => { 120 | const intentions = []; 121 | 122 | if ((classInfo == null)) { return intentions; } 123 | 124 | if (!classInfo.hasDocblock) { 125 | if (classInfo.hasDocumentation) { 126 | intentions.push({ 127 | priority : 100, 128 | icon : 'gear', 129 | title : 'Generate Docblock (inheritDoc)', 130 | 131 | selected : () => { 132 | return this.generateDocblockInheritance(editor, triggerPosition); 133 | } 134 | }); 135 | } 136 | 137 | intentions.push({ 138 | priority : 100, 139 | icon : 'gear', 140 | title : 'Generate Docblock', 141 | 142 | selected : () => { 143 | return this.generateClasslikeDocblockFor(editor, classInfo); 144 | } 145 | }); 146 | } 147 | 148 | return intentions; 149 | }; 150 | 151 | return this.service.getClassInfo(resolvedType).then(nestedSuccessHandler, failureHandler); 152 | }; 153 | 154 | return this.service.resolveType('file://' + editor.getPath(), triggerPosition, name, 'classlike') 155 | .then(successHandler, failureHandler); 156 | } 157 | 158 | /** 159 | * @param {TextEditor} editor 160 | * @param {Object} classData 161 | */ 162 | generateClasslikeDocblockFor(editor, classData) { 163 | const zeroBasedStartLine = classData.range.start.line; 164 | 165 | const indentationLevel = editor.indentationForBufferRow(zeroBasedStartLine); 166 | 167 | const docblock = this.docblockBuilder.buildByLines( 168 | [], 169 | editor.getTabText().repeat(indentationLevel) 170 | ); 171 | 172 | return editor.getBuffer().insert(new Point(zeroBasedStartLine, -1), docblock); 173 | } 174 | 175 | /** 176 | * @param {TextEditor} editor 177 | * @param {Point} triggerPosition 178 | * @param {String} name 179 | */ 180 | getFunctionlikeIntentions(editor, triggerPosition, name) { 181 | const failureHandler = () => { 182 | return []; 183 | }; 184 | 185 | const successHandler = currentClassName => { 186 | let nestedSuccessHandler; 187 | const helperFunction = functionlikeData => { 188 | const intentions = []; 189 | 190 | if (!functionlikeData) { return intentions; } 191 | 192 | if (!functionlikeData.hasDocblock) { 193 | if (functionlikeData.hasDocumentation) { 194 | intentions.push({ 195 | priority : 100, 196 | icon : 'gear', 197 | title : 'Generate Docblock (inheritDoc)', 198 | 199 | selected : () => { 200 | return this.generateDocblockInheritance(editor, triggerPosition); 201 | } 202 | }); 203 | } 204 | 205 | intentions.push({ 206 | priority : 100, 207 | icon : 'gear', 208 | title : 'Generate Docblock', 209 | 210 | selected : async () => { 211 | await this.generateFunctionlikeDocblockFor(editor, functionlikeData); 212 | } 213 | }); 214 | } 215 | 216 | return intentions; 217 | }; 218 | 219 | if (currentClassName) { 220 | nestedSuccessHandler = classInfo => { 221 | if (!(name in classInfo.methods)) { return []; } 222 | return helperFunction(classInfo.methods[name]); 223 | }; 224 | 225 | return this.service.getClassInfo(currentClassName).then(nestedSuccessHandler, failureHandler); 226 | 227 | } else { 228 | nestedSuccessHandler = globalFunctions => { 229 | if (!(name in globalFunctions)) { return []; } 230 | return helperFunction(globalFunctions[name]); 231 | }; 232 | 233 | return this.service.getGlobalFunctions().then(nestedSuccessHandler, failureHandler); 234 | } 235 | }; 236 | 237 | return this.service.determineCurrentClassName(editor, triggerPosition).then(successHandler, failureHandler); 238 | } 239 | 240 | /** 241 | * @param {TextEditor} editor 242 | * @param {Object} data 243 | */ 244 | async generateFunctionlikeDocblockFor(editor, data) { 245 | const zeroBasedStartLine = data.range.start.line; 246 | 247 | await this.localizeFunctionParametersTypes( 248 | data.parameters, 249 | 'file://' + editor.getPath(), 250 | new Point(data.range.start.line, data.range.start.character), 251 | ); 252 | 253 | const parameters = await Promise.all(data.parameters.map(async (parameter) => { 254 | let type = 'mixed'; 255 | 256 | if (parameter.types.length > 0) { 257 | type = this.typeHelper.buildTypeSpecificationFromTypeArray(parameter.types); 258 | } 259 | 260 | let name = ''; 261 | 262 | if (parameter.isReference) { 263 | name += '&'; 264 | } 265 | 266 | name += `$${parameter.name}`; 267 | 268 | if (parameter.isVariadic) { 269 | name = `...${name}`; 270 | type += '[]'; 271 | } 272 | 273 | return { 274 | name, 275 | type 276 | }; 277 | })); 278 | 279 | const indentationLevel = editor.indentationForBufferRow(zeroBasedStartLine); 280 | 281 | let returnType = null; 282 | 283 | if ((data.returnTypes.length > 0) && (data.name !== '__construct')) { 284 | await this.localizeFunctionParameterTypes( 285 | data, 286 | 'file://' + editor.getPath(), 287 | new Point(data.range.start.line, data.range.start.character), 288 | 'returnTypes' 289 | ); 290 | 291 | returnType = this.typeHelper.buildTypeSpecificationFromTypeArray(data.returnTypes); 292 | } 293 | 294 | const docblock = this.docblockBuilder.buildForMethod( 295 | parameters, 296 | returnType, 297 | false, 298 | editor.getTabText().repeat(indentationLevel) 299 | ); 300 | 301 | return editor.getBuffer().insert(new Point(zeroBasedStartLine, -1), docblock); 302 | } 303 | 304 | /** 305 | * @param {TextEditor} editor 306 | * @param {Point} triggerPosition 307 | * @param {String} name 308 | */ 309 | getPropertyIntentions(editor, triggerPosition, name) { 310 | const failureHandler = () => { 311 | return []; 312 | }; 313 | 314 | const successHandler = currentClassName => { 315 | if ((currentClassName == null)) { return []; } 316 | 317 | const nestedSuccessHandler = classInfo => { 318 | name = name.substr(1); 319 | 320 | if (!(name in classInfo.properties)) { return []; } 321 | 322 | const propertyData = classInfo.properties[name]; 323 | 324 | if ((propertyData == null)) { return; } 325 | 326 | const intentions = []; 327 | 328 | if (!propertyData) { return intentions; } 329 | 330 | if (!propertyData.hasDocblock) { 331 | if (propertyData.hasDocumentation) { 332 | intentions.push({ 333 | priority : 100, 334 | icon : 'gear', 335 | title : 'Generate Docblock (inheritDoc)', 336 | 337 | selected : () => { 338 | return this.generateDocblockInheritance(editor, triggerPosition); 339 | } 340 | }); 341 | } 342 | 343 | intentions.push({ 344 | priority : 100, 345 | icon : 'gear', 346 | title : 'Generate Docblock', 347 | 348 | selected : () => { 349 | return this.generatePropertyDocblockFor(editor, propertyData); 350 | } 351 | }); 352 | } 353 | 354 | return intentions; 355 | }; 356 | 357 | return this.service.getClassInfo(currentClassName).then(nestedSuccessHandler, failureHandler); 358 | }; 359 | 360 | return this.service.determineCurrentClassName(editor, triggerPosition).then(successHandler, failureHandler); 361 | } 362 | 363 | /** 364 | * @param {TextEditor} editor 365 | * @param {Object} data 366 | */ 367 | generatePropertyDocblockFor(editor, data) { 368 | const zeroBasedStartLine = data.range.start.line; 369 | 370 | const indentationLevel = editor.indentationForBufferRow(zeroBasedStartLine); 371 | 372 | let type = 'mixed'; 373 | 374 | if (data.types.length > 0) { 375 | type = this.typeHelper.buildTypeSpecificationFromTypeArray(data.types); 376 | } 377 | 378 | const docblock = this.docblockBuilder.buildForProperty( 379 | type, 380 | false, 381 | editor.getTabText().repeat(indentationLevel) 382 | ); 383 | 384 | return editor.getBuffer().insert(new Point(zeroBasedStartLine, -1), docblock); 385 | } 386 | 387 | /** 388 | * @param {TextEditor} editor 389 | * @param {Point} triggerPosition 390 | * @param {String} name 391 | */ 392 | getConstantIntentions(editor, triggerPosition, name) { 393 | const failureHandler = () => { 394 | return []; 395 | }; 396 | 397 | const successHandler = currentClassName => { 398 | let nestedSuccessHandler; 399 | const helperFunction = constantData => { 400 | const intentions = []; 401 | 402 | if (!constantData) { return intentions; } 403 | 404 | if (!constantData.hasDocblock) { 405 | intentions.push({ 406 | priority : 100, 407 | icon : 'gear', 408 | title : 'Generate Docblock', 409 | 410 | selected : () => { 411 | return this.generateConstantDocblockFor(editor, constantData); 412 | } 413 | }); 414 | } 415 | 416 | return intentions; 417 | }; 418 | 419 | if (currentClassName) { 420 | nestedSuccessHandler = classInfo => { 421 | if (!(name in classInfo.constants)) { return []; } 422 | return helperFunction(classInfo.constants[name]); 423 | }; 424 | 425 | return this.service.getClassInfo(currentClassName).then(nestedSuccessHandler, failureHandler); 426 | 427 | } else { 428 | nestedSuccessHandler = globalConstants => { 429 | if (!(name in globalConstants)) { return []; } 430 | return helperFunction(globalConstants[name]); 431 | }; 432 | 433 | return this.service.getGlobalConstants().then(nestedSuccessHandler, failureHandler); 434 | } 435 | }; 436 | 437 | return this.service.determineCurrentClassName(editor, triggerPosition).then(successHandler, failureHandler); 438 | } 439 | 440 | /** 441 | * @param {TextEditor} editor 442 | * @param {Object} data 443 | */ 444 | generateConstantDocblockFor(editor, data) { 445 | const zeroBasedStartLine = data.range.start.line; 446 | 447 | const indentationLevel = editor.indentationForBufferRow(zeroBasedStartLine); 448 | 449 | let type = 'mixed'; 450 | 451 | if (data.types.length > 0) { 452 | type = this.typeHelper.buildTypeSpecificationFromTypeArray(data.types); 453 | } 454 | 455 | const docblock = this.docblockBuilder.buildForProperty( 456 | type, 457 | false, 458 | editor.getTabText().repeat(indentationLevel) 459 | ); 460 | 461 | return editor.getBuffer().insert(new Point(zeroBasedStartLine, -1), docblock); 462 | } 463 | 464 | /** 465 | * @param {TextEditor} editor 466 | * @param {Point} triggerPosition 467 | */ 468 | generateDocblockInheritance(editor, triggerPosition) { 469 | const indentationLevel = editor.indentationForBufferRow(triggerPosition.row); 470 | 471 | const docblock = this.docblockBuilder.buildByLines( 472 | ['@inheritDoc'], 473 | editor.getTabText().repeat(indentationLevel) 474 | ); 475 | 476 | return editor.getBuffer().insert(new Point(triggerPosition.row, -1), docblock); 477 | } 478 | }; 479 | -------------------------------------------------------------------------------- /lib/Refactoring/ExtractMethodProvider.js: -------------------------------------------------------------------------------- 1 | /* global atom */ 2 | 3 | /* 4 | * decaffeinate suggestions: 5 | * DS102: Remove unnecessary code created because of implicit returns 6 | * DS207: Consider shorter variations of null checks 7 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 8 | */ 9 | const {Range} = require('atom'); 10 | 11 | const AbstractProvider = require('./AbstractProvider'); 12 | 13 | module.exports = 14 | 15 | //#* 16 | // Provides method extraction capabilities. 17 | //# 18 | class ExtractMethodProvider extends AbstractProvider { 19 | /** 20 | * @param {Object} builder 21 | */ 22 | constructor(builder) { 23 | super(); 24 | 25 | /** 26 | * View that the user interacts with when extracting code. 27 | * 28 | * @type {Object} 29 | */ 30 | this.view = null; 31 | 32 | /** 33 | * Builder used to generate the new method. 34 | * 35 | * @type {Object} 36 | */ 37 | this.builder = builder; 38 | } 39 | 40 | /** 41 | * @inheritdoc 42 | */ 43 | activate(service) { 44 | super.activate(service); 45 | 46 | return atom.commands.add('atom-text-editor', { 'php-ide-serenata:extract-method': () => { 47 | return this.executeCommand(); 48 | } 49 | } 50 | ); 51 | } 52 | 53 | /** 54 | * @inheritdoc 55 | */ 56 | deactivate() { 57 | super.deactivate(); 58 | 59 | if (this.view) { 60 | this.view.destroy(); 61 | this.view = null; 62 | } 63 | } 64 | 65 | /** 66 | * @inheritdoc 67 | */ 68 | getIntentionProviders() { 69 | return [{ 70 | grammarScopes: ['source.php'], 71 | getIntentions: () => { 72 | const activeTextEditor = atom.workspace.getActiveTextEditor(); 73 | 74 | if (!activeTextEditor) { return []; } 75 | if ((this.getCurrentProjectPhpVersion() == null)) { return []; } 76 | 77 | const selection = activeTextEditor.getSelectedBufferRange(); 78 | 79 | // Checking if a selection has been made 80 | if ( 81 | (selection.start.row === selection.end.row) && 82 | (selection.start.column === selection.end.column) 83 | ) { 84 | return []; 85 | } 86 | 87 | return [ 88 | { 89 | priority : 200, 90 | icon : 'git-branch', 91 | title : 'Extract Method', 92 | 93 | selected : () => { 94 | return this.executeCommand(); 95 | } 96 | } 97 | ]; 98 | } 99 | }]; 100 | } 101 | 102 | /** 103 | * Executes the extraction. 104 | */ 105 | executeCommand() { 106 | const activeTextEditor = atom.workspace.getActiveTextEditor(); 107 | 108 | if (!activeTextEditor) { return; } 109 | 110 | const tabText = activeTextEditor.getTabText(); 111 | 112 | const selection = activeTextEditor.getSelectedBufferRange(); 113 | 114 | // Checking if a selection has been made 115 | if ((selection.start.row === selection.end.row) && (selection.start.column === selection.end.column)) { 116 | atom.notifications.addInfo('Serenata', { 117 | description: 'Please select the code to extract and try again.' 118 | }); 119 | 120 | return; 121 | } 122 | 123 | const line = activeTextEditor.lineTextForBufferRow(selection.start.row); 124 | 125 | const findSingleTab = new RegExp(`(${tabText})`, 'g'); 126 | 127 | const matches = (line.match(findSingleTab) || []).length; 128 | 129 | // If the first line doesn't have any tabs then add one. 130 | let highlightedText = activeTextEditor.getTextInBufferRange(selection); 131 | const selectedBufferFirstLine = highlightedText.split('\n')[0]; 132 | 133 | if ((selectedBufferFirstLine.match(findSingleTab) || []).length === 0) { 134 | highlightedText = `${tabText}` + highlightedText; 135 | } 136 | 137 | // Replacing double indents with one, so it can be shown in the preview area of panel. 138 | const multipleTabTexts = Array(matches).fill(`${tabText}`); 139 | const findMultipleTab = new RegExp(`^${multipleTabTexts.join('')}`, 'mg'); 140 | const reducedHighlightedText = highlightedText.replace(findMultipleTab, `${tabText}`); 141 | 142 | this.builder.setEditor(activeTextEditor); 143 | this.builder.setMethodBody(reducedHighlightedText); 144 | 145 | this.getView().storeFocusedElement(); 146 | return this.getView().present(); 147 | } 148 | 149 | /** 150 | * Called when the user has cancel the extraction in the modal. 151 | */ 152 | onCancel() { 153 | return this.builder.cleanUp(); 154 | } 155 | 156 | /** 157 | * Called when the user has confirmed the extraction in the modal. 158 | * 159 | * @param {Object} settings 160 | * 161 | * @see ParameterParser.buildMethod for structure of settings 162 | */ 163 | onConfirm(settings) { 164 | const successHandler = methodCall => { 165 | const activeTextEditor = atom.workspace.getActiveTextEditor(); 166 | 167 | const selectedBufferRange = activeTextEditor.getSelectedBufferRange(); 168 | 169 | const highlightedBufferPosition = selectedBufferRange.end; 170 | 171 | let row = 0; 172 | 173 | // eslint-disable-next-line no-constant-condition 174 | while (true) { 175 | row++; 176 | const descriptions = activeTextEditor.scopeDescriptorForBufferPosition( 177 | [highlightedBufferPosition.row + row, activeTextEditor.getTabLength()] 178 | ); 179 | const indexOfDescriptor = descriptions.scopes.indexOf('punctuation.section.scope.end.php'); 180 | if ((indexOfDescriptor > -1) || (row === activeTextEditor.getLineCount())) { break; } 181 | } 182 | 183 | row = highlightedBufferPosition.row + row; 184 | 185 | const line = activeTextEditor.lineTextForBufferRow(row); 186 | 187 | const endOfLine = line != null ? line.length : undefined; 188 | 189 | let replaceRange = [ 190 | [row, 0], 191 | [row, endOfLine] 192 | ]; 193 | 194 | const previousText = activeTextEditor.getTextInBufferRange(replaceRange); 195 | 196 | settings.tabs = true; 197 | 198 | const nestedSuccessHandler = newMethodBody => { 199 | settings.tabs = false; 200 | 201 | this.builder.cleanUp(); 202 | 203 | return activeTextEditor.transact(() => { 204 | // Matching current indentation 205 | const selectedText = activeTextEditor.getSelectedText(); 206 | let spacing = selectedText.match(/^\s*/); 207 | if (spacing !== null) { 208 | spacing = spacing[0]; 209 | } 210 | 211 | activeTextEditor.insertText(spacing + methodCall); 212 | 213 | // Remove any extra new lines between functions 214 | const nextLine = activeTextEditor.lineTextForBufferRow(row + 1); 215 | if (nextLine === '') { 216 | activeTextEditor.setSelectedBufferRange( 217 | [ 218 | [row + 1, 0], 219 | [row + 1, 1] 220 | ] 221 | ); 222 | activeTextEditor.deleteLine(); 223 | } 224 | 225 | 226 | // Re working out range as inserting method call will delete some 227 | // lines and thus offsetting this 228 | row -= selectedBufferRange.end.row - selectedBufferRange.start.row; 229 | 230 | if (this.snippetManager != null) { 231 | activeTextEditor.setCursorBufferPosition([row + 1, 0]); 232 | 233 | const body = `\n${newMethodBody}`; 234 | 235 | const result = this.getTabStopsForBody(body); 236 | 237 | const snippet = { 238 | body, 239 | lineCount: result.lineCount, 240 | tabStopList: result.tabStops 241 | }; 242 | 243 | snippet.tabStopList.toArray = () => { 244 | return snippet.tabStopList; 245 | }; 246 | 247 | return this.snippetManager.insertSnippet( 248 | snippet, 249 | activeTextEditor 250 | ); 251 | } else { 252 | // Re working out range as inserting method call will delete some 253 | // lines and thus offsetting this 254 | row -= selectedBufferRange.end.row - selectedBufferRange.start.row; 255 | 256 | replaceRange = [ 257 | [row, 0], 258 | [row, (line != null ? line.length : undefined)] 259 | ]; 260 | 261 | return activeTextEditor.setTextInBufferRange( 262 | replaceRange, 263 | `${previousText}\n\n${newMethodBody}` 264 | ); 265 | } 266 | }); 267 | }; 268 | 269 | const nestedFailureHandler = () => { 270 | settings.tabs = false; 271 | }; 272 | 273 | return this.builder.buildMethod(settings).then(nestedSuccessHandler, nestedFailureHandler); 274 | }; 275 | 276 | const failureHandler = () => {}; 277 | // Do nothing. 278 | 279 | return this.builder.buildMethodCall(settings.methodName).then(successHandler, failureHandler); 280 | } 281 | 282 | /** 283 | * Gets all the tab stops and line count for the body given 284 | * 285 | * @param {String} body 286 | * 287 | * @return {Object} 288 | */ 289 | getTabStopsForBody(body) { 290 | const lines = body.split('\n'); 291 | let row = 0; 292 | let lineCount = 0; 293 | let tabStops = []; 294 | const tabStopIndex = {}; 295 | 296 | for (const line of lines) { 297 | var match; 298 | const regex = /(\[[\w ]*?\])(\s*\$[a-zA-Z0-9_]+)?/g; 299 | // Get tab stops by looping through all matches 300 | while ((match = regex.exec(line)) !== null) { 301 | let key = match[2]; // 2nd capturing group (variable name) 302 | const range = new Range( 303 | [row, match.index], 304 | [row, match.index + match[1].length] 305 | ); 306 | 307 | if (key !== undefined) { 308 | key = key.trim(); 309 | if (tabStopIndex[key] !== undefined) { 310 | tabStopIndex[key].push(range); 311 | } else { 312 | tabStopIndex[key] = [range]; 313 | } 314 | } else { 315 | tabStops.push([range]); 316 | } 317 | } 318 | 319 | row++; 320 | lineCount++; 321 | } 322 | 323 | for (const objectKey of Object.keys(tabStopIndex)) { 324 | tabStops.push(tabStopIndex[objectKey]); 325 | } 326 | 327 | tabStops = tabStops.sort(this.sortTabStops); 328 | 329 | return { 330 | tabStops, 331 | lineCount 332 | }; 333 | } 334 | 335 | /** 336 | * Sorts the tab stops by their row and column 337 | * 338 | * @param {Array} a 339 | * @param {Array} b 340 | * 341 | * @return {Integer} 342 | */ 343 | sortTabStops(a, b) { 344 | // Grabbing first range in the array 345 | a = a[0]; 346 | b = b[0]; 347 | 348 | // b is before a in the rows 349 | if (a.start.row > b.start.row) { 350 | return 1; 351 | } 352 | 353 | // a is before b in the rows 354 | if (a.start.row < b.start.row) { 355 | return -1; 356 | } 357 | 358 | // On same line but b is before a 359 | if (a.start.column > b.start.column) { 360 | return 1; 361 | } 362 | 363 | // On same line but a is before b 364 | if (a.start.column < b.start.column) { 365 | return -1; 366 | } 367 | 368 | // Same position 369 | return 0; 370 | } 371 | 372 | /** 373 | * @return {View} 374 | */ 375 | getView() { 376 | if ((this.view == null)) { 377 | const View = require('./ExtractMethodProvider/View'); 378 | 379 | this.view = new View(this.onConfirm.bind(this), this.onCancel.bind(this)); 380 | this.view.setBuilder(this.builder); 381 | } 382 | 383 | return this.view; 384 | } 385 | }; 386 | -------------------------------------------------------------------------------- /lib/Refactoring/ExtractMethodProvider/Builder.js: -------------------------------------------------------------------------------- 1 | /* global atom */ 2 | 3 | /* 4 | * decaffeinate suggestions: 5 | * DS102: Remove unnecessary code created because of implicit returns 6 | * DS207: Consider shorter variations of null checks 7 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 8 | */ 9 | const {Range} = require('atom'); 10 | 11 | module.exports = 12 | 13 | class Builder { 14 | /** 15 | * Constructor. 16 | * 17 | * @param {Object} parameterParser 18 | * @param {Object} docblockBuilder 19 | * @param {Object} functionBuilder 20 | * @param {Object} typeHelper 21 | */ 22 | constructor(parameterParser, docblockBuilder, functionBuilder, typeHelper) { 23 | /** 24 | * The body of the new method that will be shown in the preview area. 25 | * 26 | * @type {String} 27 | */ 28 | this.methodBody = ''; 29 | 30 | /** 31 | * The tab string that is used by the current editor. 32 | * 33 | * @type {String} 34 | */ 35 | this.tabText = ''; 36 | 37 | /** 38 | * @type {Number} 39 | */ 40 | this.indentationLevel = null; 41 | 42 | /** 43 | * @type {Number} 44 | */ 45 | this.maxLineLength = null; 46 | 47 | /** 48 | * The php-ide-serenata service. 49 | * 50 | * @type {Service} 51 | */ 52 | this.service = null; 53 | 54 | /** 55 | * A range of the selected/highlighted area of code to analyse. 56 | * 57 | * @type {Range} 58 | */ 59 | this.selectedBufferRange = null; 60 | 61 | /** 62 | * The text editor to be analysing. 63 | * 64 | * @type {TextEditor} 65 | */ 66 | this.editor = null; 67 | 68 | /** 69 | * The parameter parser that will work out the parameters the 70 | * selectedBufferRange will need. 71 | * 72 | * @type {Object} 73 | */ 74 | this.parameterParser = parameterParser; 75 | 76 | /** 77 | * All the variables to return 78 | * 79 | * @type {Array} 80 | */ 81 | this.returnVariables = null; 82 | 83 | /** 84 | * @type {Object} 85 | */ 86 | this.docblockBuilder = docblockBuilder; 87 | 88 | /** 89 | * @type {Object} 90 | */ 91 | this.functionBuilder = functionBuilder; 92 | 93 | /** 94 | * @type {Object} 95 | */ 96 | this.typeHelper = typeHelper; 97 | } 98 | 99 | /** 100 | * Sets the method body to use in the preview. 101 | * 102 | * @param {String} text 103 | */ 104 | setMethodBody(text) { 105 | this.methodBody = text; 106 | } 107 | 108 | /** 109 | * The tab string to use when generating the new method. 110 | * 111 | * @param {String} tab 112 | */ 113 | setTabText(tab) { 114 | this.tabText = tab; 115 | } 116 | 117 | /** 118 | * @param {Number} indentationLevel 119 | */ 120 | setIndentationLevel(indentationLevel) { 121 | this.indentationLevel = indentationLevel; 122 | } 123 | 124 | /** 125 | * @param {Number} maxLineLength 126 | */ 127 | setMaxLineLength(maxLineLength) { 128 | this.maxLineLength = maxLineLength; 129 | } 130 | 131 | /** 132 | * Set the php-ide-serenata service to be used. 133 | * 134 | * @param {Service} service 135 | */ 136 | setService(service) { 137 | this.service = service; 138 | this.parameterParser.setService(service); 139 | } 140 | 141 | /** 142 | * Set the selectedBufferRange to analyse. 143 | * 144 | * @param {Range} range [description] 145 | */ 146 | setSelectedBufferRange(range) { 147 | this.selectedBufferRange = range; 148 | } 149 | 150 | /** 151 | * Set the TextEditor to be used when analysing the selectedBufferRange 152 | * 153 | * @param {TextEditor} editor [description] 154 | */ 155 | setEditor(editor) { 156 | this.editor = editor; 157 | this.setTabText(editor.getTabText()); 158 | this.setIndentationLevel(1); 159 | this.setMaxLineLength( 160 | atom.config.get('editor.preferredLineLength', 161 | editor.getLastCursor().getScopeDescriptor()) 162 | ); 163 | this.setSelectedBufferRange(editor.getSelectedBufferRange()); 164 | } 165 | 166 | /** 167 | * Builds the new method from the selectedBufferRange and settings given. 168 | * 169 | * The settings parameter should be an object with these properties: 170 | * - methodName (string) 171 | * - visibility (string) ['private', 'protected', 'public'] 172 | * - tabs (boolean) 173 | * - generateDocs (boolean) 174 | * - arraySyntax (string) ['word', 'brackets'] 175 | * - generateDocPlaceholders (boolean) 176 | * 177 | * @param {Object} settings 178 | * 179 | * @return {Promise} 180 | */ 181 | buildMethod(settings) { 182 | const successHandler = parameters => { 183 | if (this.returnVariables === null) { 184 | this.returnVariables = this.workOutReturnVariables(this.parameterParser.getVariableDeclarations()); 185 | } 186 | 187 | const tabText = settings.tabs ? this.tabText : ''; 188 | const totalIndentation = tabText.repeat(this.indentationLevel); 189 | 190 | const statements = []; 191 | 192 | for (const statement of this.methodBody.split('\n')) { 193 | const newStatement = statement.substr(totalIndentation.length); 194 | 195 | statements.push(newStatement); 196 | } 197 | 198 | let returnTypeHintSpecification = 'void'; 199 | let returnStatement = this.buildReturnStatement(this.returnVariables, settings.arraySyntax); 200 | 201 | if (returnStatement != null) { 202 | if (this.returnVariables.length === 1) { 203 | returnTypeHintSpecification = this.returnVariables[0].types.join('|'); 204 | 205 | } else { 206 | returnTypeHintSpecification = 'array'; 207 | } 208 | 209 | returnStatement = returnStatement.substr(totalIndentation.length); 210 | 211 | statements.push(''); 212 | statements.push(returnStatement); 213 | } 214 | 215 | const functionParameters = parameters.map(parameter => { 216 | const typeHintInfo = this.typeHelper.getTypeHintForDocblockTypes(parameter.types); 217 | 218 | return { 219 | name : parameter.name, 220 | typeHint : (typeHintInfo != null) && settings.typeHinting ? typeHintInfo.typeHint : null, 221 | defaultValue : (typeHintInfo != null) && typeHintInfo.isNullable ? 'null' : null 222 | }; 223 | }); 224 | 225 | const docblockParameters = parameters.map(parameter => { 226 | const typeSpecification = this.typeHelper.buildTypeSpecificationFromTypes(parameter.types); 227 | 228 | return { 229 | name : parameter.name, 230 | type : typeSpecification.length > 0 ? typeSpecification : '[type]' 231 | }; 232 | }); 233 | 234 | this.functionBuilder 235 | .setIsStatic(false) 236 | .setIsAbstract(false) 237 | .setName(settings.methodName) 238 | .setReturnType(this.typeHelper.getReturnTypeHintForTypeSpecification(returnTypeHintSpecification)) 239 | .setParameters(functionParameters) 240 | .setStatements(statements) 241 | .setIndentationLevel(this.indentationLevel) 242 | .setTabText(tabText) 243 | .setMaxLineLength(this.maxLineLength); 244 | 245 | if (settings.visibility === 'public') { 246 | this.functionBuilder.makePublic(); 247 | 248 | } else if (settings.visibility === 'protected') { 249 | this.functionBuilder.makeProtected(); 250 | 251 | } else if (settings.visibility === 'private') { 252 | this.functionBuilder.makePrivate(); 253 | 254 | } else { 255 | this.functionBuilder.makeGlobal(); 256 | } 257 | 258 | let docblockText = ''; 259 | 260 | if (settings.generateDocs) { 261 | let returnType = 'void'; 262 | 263 | if ((this.returnVariables !== null) && (this.returnVariables.length > 0)) { 264 | returnType = '[type]'; 265 | 266 | if (this.returnVariables.length > 1) { 267 | returnType = 'array'; 268 | 269 | } else if ((this.returnVariables.length === 1) && (this.returnVariables[0].types.length > 0)) { 270 | returnType = this.typeHelper.buildTypeSpecificationFromTypes(this.returnVariables[0].types); 271 | } 272 | } 273 | 274 | docblockText = this.docblockBuilder.buildForMethod( 275 | docblockParameters, 276 | returnType, 277 | settings.generateDescPlaceholders, 278 | totalIndentation 279 | ); 280 | } 281 | 282 | return docblockText + this.functionBuilder.build(); 283 | }; 284 | 285 | const failureHandler = () => null; 286 | 287 | return this.parameterParser.findParameters(this.editor, this.selectedBufferRange) 288 | .then(successHandler, failureHandler); 289 | } 290 | 291 | /** 292 | * Build the line that calls the new method and the variable the method 293 | * to be assigned to. 294 | * 295 | * @param {String} methodName 296 | * @param {String} variable [Optional] 297 | * 298 | * @return {Promise} 299 | */ 300 | buildMethodCall(methodName, variable) { 301 | const successHandler = parameters => { 302 | const parameterNames = parameters.map(item => item.name); 303 | 304 | let methodCall = `$this->${methodName}(${parameterNames.join(', ')});`; 305 | 306 | if (variable !== undefined) { 307 | methodCall = `$${variable} = ${methodCall}`; 308 | } else { 309 | if (this.returnVariables !== null) { 310 | if (this.returnVariables.length === 1) { 311 | methodCall = `${this.returnVariables[0].name} = ${methodCall}`; 312 | } else if (this.returnVariables.length > 1) { 313 | const variables = this.returnVariables.reduce(function(previous, current) { 314 | if (typeof previous !== 'string') { 315 | previous = previous.name; 316 | } 317 | 318 | return previous + ', ' + current.name; 319 | }); 320 | 321 | methodCall = `list(${variables}) = ${methodCall}`; 322 | } 323 | } 324 | } 325 | 326 | return methodCall; 327 | }; 328 | 329 | const failureHandler = () => null; 330 | 331 | return this.parameterParser.findParameters(this.editor, this.selectedBufferRange) 332 | .then(successHandler, failureHandler); 333 | } 334 | 335 | /** 336 | * Performs any clean up needed with the builder. 337 | */ 338 | cleanUp() { 339 | this.returnVariables = null; 340 | return this.parameterParser.cleanUp(); 341 | } 342 | 343 | /** 344 | * Works out which variables need to be returned from the new method. 345 | * 346 | * @param {Array} variableDeclarations 347 | * 348 | * @return {Array} 349 | */ 350 | workOutReturnVariables(variableDeclarations) { 351 | const startPoint = this.selectedBufferRange.end; 352 | const scopeRange = this.parameterParser.getRangeForCurrentScope(this.editor, startPoint); 353 | 354 | const lookupRange = new Range(startPoint, scopeRange.end); 355 | 356 | const textAfterExtraction = this.editor.getTextInBufferRange(lookupRange); 357 | const allVariablesAfterExtraction = textAfterExtraction.match(/\$[a-zA-Z0-9]+/g); 358 | 359 | if (allVariablesAfterExtraction === null) { return null; } 360 | 361 | variableDeclarations = variableDeclarations.filter(variable => { 362 | if (allVariablesAfterExtraction.includes(variable.name)) { return true; } 363 | return false; 364 | }); 365 | 366 | return variableDeclarations; 367 | } 368 | 369 | /** 370 | * Builds the return statement for the new method. 371 | * 372 | * @param {Array} variableDeclarations 373 | * @param {String} arrayType ['word', 'brackets'] 374 | * 375 | * @return {String|null} 376 | */ 377 | buildReturnStatement(variableDeclarations, arrayType) { 378 | if (arrayType == null) { arrayType = 'word'; } 379 | if (variableDeclarations != null) { 380 | if (variableDeclarations.length === 1) { 381 | return `${this.tabText}return ${variableDeclarations[0].name};`; 382 | 383 | } else if (variableDeclarations.length > 1) { 384 | let variables = variableDeclarations.reduce(function(previous, current) { 385 | if (typeof previous !== 'string') { 386 | previous = previous.name; 387 | } 388 | 389 | return previous + ', ' + current.name; 390 | }); 391 | 392 | if (arrayType === 'brackets') { 393 | variables = `[${variables}]`; 394 | 395 | } else { 396 | variables = `array(${variables})`; 397 | } 398 | 399 | return `${this.tabText}return ${variables};`; 400 | } 401 | } 402 | 403 | return null; 404 | } 405 | 406 | /** 407 | * Checks if the new method will be returning any values. 408 | * 409 | * @return {Boolean} 410 | */ 411 | hasReturnValues() { 412 | return (this.returnVariables !== null) && (this.returnVariables.length > 0); 413 | } 414 | 415 | /** 416 | * Returns if there are multiple return values. 417 | * 418 | * @return {Boolean} 419 | */ 420 | hasMultipleReturnValues() { 421 | return (this.returnVariables !== null) && (this.returnVariables.length > 1); 422 | } 423 | }; 424 | -------------------------------------------------------------------------------- /lib/Refactoring/ExtractMethodProvider/ParameterParser.js: -------------------------------------------------------------------------------- 1 | /* 2 | * decaffeinate suggestions: 3 | * DS102: Remove unnecessary code created because of implicit returns 4 | * DS202: Simplify dynamic range loops 5 | * DS207: Consider shorter variations of null checks 6 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 7 | */ 8 | const {Point, Range} = require('atom'); 9 | 10 | module.exports = 11 | 12 | class ParameterParser { 13 | /** 14 | * Constructor 15 | * 16 | * @param {Object} typeHelper 17 | */ 18 | constructor(typeHelper) { 19 | /** 20 | * Service object from the php-ide-serenata service 21 | * 22 | * @type {Service} 23 | */ 24 | this.service = null; 25 | 26 | /** 27 | * @type {Object} 28 | */ 29 | this.typeHelper = typeHelper; 30 | 31 | /** 32 | * List of all the variable declarations that have been process 33 | * 34 | * @type {Array} 35 | */ 36 | this.variableDeclarations = []; 37 | 38 | /** 39 | * The selected range that we are scanning for parameters in. 40 | * 41 | * @type {Range} 42 | */ 43 | this.selectedBufferRange = null; 44 | } 45 | 46 | /** 47 | * @param {Object} service 48 | */ 49 | setService(service) { 50 | this.service = service; 51 | } 52 | 53 | /** 54 | * Takes the editor and the range and loops through finding all the 55 | * parameters that will be needed if this code was to be moved into 56 | * its own function 57 | * 58 | * @param {TextEditor} editor 59 | * @param {Range} selectedBufferRange 60 | * 61 | * @return {Promise} 62 | */ 63 | findParameters(editor, selectedBufferRange) { 64 | this.selectedBufferRange = selectedBufferRange; 65 | 66 | let parameters = []; 67 | 68 | editor.scanInBufferRange(/\$[a-zA-Z0-9_]+/g, selectedBufferRange, element => { 69 | // Making sure we matched a variable and not a variable within a string 70 | const descriptions = editor.scopeDescriptorForBufferPosition(element.range.start); 71 | const indexOfDescriptor = descriptions.scopes.indexOf('variable.other.php'); 72 | if (indexOfDescriptor > -1) { 73 | return parameters.push({ 74 | name: element.matchText, 75 | range: element.range 76 | }); 77 | } 78 | }); 79 | 80 | const regexFilters = [ 81 | { 82 | name: 'Foreach loops', 83 | regex: /as\s(\$[a-zA-Z0-9_]+)(?:\s=>\s(\$[a-zA-Z0-9_]+))?/g 84 | }, 85 | { 86 | name: 'For loops', 87 | regex: /for\s*\(\s*(\$[a-zA-Z0-9_]+)\s*=/g 88 | }, 89 | { 90 | name: 'Try catch', 91 | regex: /catch(?:\(|\s)+.*?(\$[a-zA-Z0-9_]+)/g 92 | }, 93 | { 94 | name: 'Closure', 95 | regex: /function(?:\s)*?\((?:\$).*?\)/g 96 | }, 97 | { 98 | name: 'Variable declarations', 99 | regex: /(\$[a-zA-Z0-9]+)\s*?=(?!>|=)/g 100 | } 101 | ]; 102 | 103 | const getTypePromises = []; 104 | const variableDeclarations = []; 105 | 106 | for (const filter of regexFilters) { 107 | editor.backwardsScanInBufferRange(filter.regex, selectedBufferRange, element => { 108 | const variables = element.matchText.match(/\$[a-zA-Z0-9]+/g); 109 | const startPoint = new Point(element.range.end.row, 0); 110 | const scopeRange = this.getRangeForCurrentScope(editor, startPoint); 111 | 112 | if (filter.name === 'Variable declarations') { 113 | let chosenParameter = null; 114 | for (const parameter of parameters) { 115 | if (element.range.containsRange(parameter.range)) { 116 | chosenParameter = parameter; 117 | break; 118 | } 119 | } 120 | 121 | if (chosenParameter !== null) { 122 | getTypePromises.push((this.getTypesForParameter(editor, chosenParameter))); 123 | variableDeclarations.push(chosenParameter); 124 | } 125 | } 126 | 127 | return variables.map((variable) => { 128 | (parameters = parameters.filter(parameter => { 129 | if (parameter.name !== variable) { 130 | return true; 131 | } 132 | if (scopeRange.containsRange(parameter.range)) { 133 | // If variable declaration is after parameter then it's 134 | // still needed in parameters. 135 | if (element.range.start.row > parameter.range.start.row) { 136 | return true; 137 | } 138 | if ((element.range.start.row === parameter.range.start.row) && 139 | (element.range.start.column > parameter.range.start.column)) { 140 | return true; 141 | } 142 | 143 | return false; 144 | } 145 | 146 | return true; 147 | })); 148 | }); 149 | }); 150 | } 151 | 152 | this.variableDeclarations = this.makeUnique(variableDeclarations); 153 | 154 | parameters = this.makeUnique(parameters); 155 | 156 | // Grab the variable types of the parameters. 157 | const promises = []; 158 | 159 | parameters.forEach(parameter => { 160 | // Removing $this from parameters as this doesn't need to be passed in. 161 | if (parameter.name === '$this') { return; } 162 | 163 | return promises.push(this.getTypesForParameter(editor, parameter)); 164 | }); 165 | 166 | const returnFirstResultHandler = resultArray => resultArray[0]; 167 | 168 | return Promise.all([Promise.all(promises), Promise.all(getTypePromises)]).then(returnFirstResultHandler); 169 | } 170 | 171 | /** 172 | * Takes the current buffer position and returns a range of the current 173 | * scope that the buffer position is in. 174 | * 175 | * For example this could be the code within an if statement or closure. 176 | * 177 | * @param {TextEditor} editor 178 | * @param {Point} bufferPosition 179 | * 180 | * @return {Range} 181 | */ 182 | getRangeForCurrentScope(editor, bufferPosition) { 183 | let descriptions, i, indexOfDescriptor, line, row; 184 | let asc; 185 | let asc2, end; 186 | let startScopePoint = null; 187 | let endScopePoint = null; 188 | 189 | // Tracks any extra scopes that might exist inside the scope we are 190 | // looking for. 191 | let childScopes = 0; 192 | 193 | // First walk back until we find the start of the current scope. 194 | for ( 195 | ({ row } = bufferPosition), asc = bufferPosition.row <= 0; 196 | asc ? row <= 0 : row >= 0; 197 | asc ? row++ : row-- 198 | ) { 199 | var asc1; 200 | line = editor.lineTextForBufferRow(row); 201 | 202 | if (!line) { continue; } 203 | 204 | const lastIndex = line.length - 1; 205 | 206 | for (i = lastIndex, asc1 = lastIndex <= 0; asc1 ? i <= 0 : i >= 0; asc1 ? i++ : i--) { 207 | descriptions = editor.scopeDescriptorForBufferPosition( 208 | [row, i] 209 | ); 210 | 211 | indexOfDescriptor = descriptions.scopes.indexOf('punctuation.section.scope.end.php'); 212 | if (indexOfDescriptor > -1) { 213 | childScopes++; 214 | } 215 | 216 | indexOfDescriptor = descriptions.scopes.indexOf('punctuation.section.scope.begin.php'); 217 | if (indexOfDescriptor > -1) { 218 | childScopes--; 219 | 220 | if (childScopes === -1) { 221 | startScopePoint = new Point(row, 0); 222 | break; 223 | } 224 | } 225 | } 226 | 227 | if (startScopePoint != null) { break; } 228 | } 229 | 230 | if (startScopePoint === null) { 231 | startScopePoint = new Point(0, 0); 232 | } 233 | 234 | childScopes = 0; 235 | 236 | // Walk forward until we find the end of the current scope 237 | for ( 238 | ({ row } = startScopePoint), end = editor.getLineCount(), asc2 = startScopePoint.row <= end; 239 | asc2 ? row <= end : row >= end; 240 | asc2 ? row++ : row-- 241 | ) { 242 | var asc3, end1; 243 | line = editor.lineTextForBufferRow(row); 244 | 245 | if (!line) { continue; } 246 | 247 | let startIndex = 0; 248 | 249 | if (startScopePoint.row === row) { 250 | startIndex = line.length - 1; 251 | } 252 | 253 | for ( 254 | i = startIndex, end1 = line.length - 1, asc3 = startIndex <= end1; 255 | asc3 ? i <= end1 : i >= end1; 256 | asc3 ? i++ : i-- 257 | ) { 258 | descriptions = editor.scopeDescriptorForBufferPosition( 259 | [row, i] 260 | ); 261 | 262 | indexOfDescriptor = descriptions.scopes.indexOf('punctuation.section.scope.begin.php'); 263 | if (indexOfDescriptor > -1) { 264 | childScopes++; 265 | } 266 | 267 | indexOfDescriptor = descriptions.scopes.indexOf('punctuation.section.scope.end.php'); 268 | if (indexOfDescriptor > -1) { 269 | if (childScopes > 0) { 270 | childScopes--; 271 | } 272 | 273 | if (childScopes === 0) { 274 | endScopePoint = new Point(row, i + 1); 275 | break; 276 | } 277 | } 278 | } 279 | 280 | if (endScopePoint != null) { break; } 281 | } 282 | 283 | return new Range(startScopePoint, endScopePoint); 284 | } 285 | 286 | /** 287 | * Takes an array of parameters and removes any parameters that appear more 288 | * that once with the same name. 289 | * 290 | * @param {Array} array 291 | * 292 | * @return {Array} 293 | */ 294 | makeUnique(array) { 295 | return array.filter(function(filterItem, pos, self) { 296 | for (let i = 0, end = self.length - 1, asc = 0 <= end; asc ? i <= end : i >= end; asc ? i++ : i--) { 297 | if (self[i].name !== filterItem.name) { 298 | continue; 299 | } 300 | 301 | return pos === i; 302 | } 303 | return true; 304 | }); 305 | } 306 | /** 307 | * Generates the key used to store the parameters in the cache. 308 | * 309 | * @param {TextEditor} editor 310 | * @param {Range} selectedBufferRange 311 | * 312 | * @return {String} 313 | */ 314 | buildKey(editor, selectedBufferRange) { 315 | return editor.getPath() + JSON.stringify(selectedBufferRange); 316 | } 317 | 318 | /** 319 | * Gets the type for the parameter given. 320 | * 321 | * @param {TextEditor} editor 322 | * @param {Object} parameter 323 | * 324 | * @return {Promise} 325 | */ 326 | getTypesForParameter(editor, parameter) { 327 | const successHandler = types => { 328 | parameter.types = types; 329 | 330 | const typeResolutionPromises = []; 331 | const path = 'file://' + editor.getPath(); 332 | 333 | const localizeTypeSuccessHandler = localizedType => { 334 | return localizedType; 335 | }; 336 | 337 | const localizeTypeFailureHandler = () => null; 338 | 339 | for (const fqcn of parameter.types) { 340 | if (this.typeHelper.isClassType(fqcn)) { 341 | const typeResolutionPromise = this.service.localizeType( 342 | path, 343 | this.selectedBufferRange.end, 344 | fqcn, 345 | 'classlike' 346 | ); 347 | 348 | typeResolutionPromises.push(typeResolutionPromise.then( 349 | localizeTypeSuccessHandler, 350 | localizeTypeFailureHandler 351 | ) 352 | ); 353 | 354 | } else { 355 | typeResolutionPromises.push(Promise.resolve(fqcn)); 356 | } 357 | } 358 | 359 | const combineResolvedTypesHandler = function(processedTypeArray) { 360 | parameter.types = processedTypeArray; 361 | 362 | return parameter; 363 | }; 364 | 365 | return Promise.all(typeResolutionPromises).then( 366 | combineResolvedTypesHandler, 367 | combineResolvedTypesHandler 368 | ); 369 | }; 370 | 371 | const failureHandler = () => { 372 | return null; 373 | }; 374 | 375 | return this.service.deduceTypesAt(parameter.name, editor, this.selectedBufferRange.end).then( 376 | successHandler, 377 | failureHandler 378 | ); 379 | } 380 | 381 | /** 382 | * Returns all the variable declarations that have been parsed. 383 | * 384 | * @return {Array} 385 | */ 386 | getVariableDeclarations() { 387 | return this.variableDeclarations; 388 | } 389 | 390 | /** 391 | * Clean up any data from previous usage 392 | */ 393 | cleanUp() { 394 | this.variableDeclarations = []; 395 | } 396 | }; 397 | -------------------------------------------------------------------------------- /lib/Refactoring/ExtractMethodProvider/View.js: -------------------------------------------------------------------------------- 1 | /* global atom */ 2 | 3 | /* 4 | * decaffeinate suggestions: 5 | * DS102: Remove unnecessary code created because of implicit returns 6 | * DS207: Consider shorter variations of null checks 7 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 8 | */ 9 | const {$, TextEditorView, View} = require('atom-space-pen-views'); 10 | 11 | module.exports = 12 | 13 | class ExtractMethodView extends View { 14 | /** 15 | * Constructor. 16 | * 17 | * @param {Callback} onDidConfirm 18 | * @param {Callback} onDidCancel 19 | */ 20 | constructor(onDidConfirm, onDidCancel = null) { 21 | super(); 22 | 23 | /** 24 | * The callback to invoke when the user confirms his selections. 25 | * 26 | * @type {Callback} 27 | */ 28 | this.onDidConfirm = onDidConfirm; 29 | 30 | /** 31 | * The callback to invoke when the user cancels the view. 32 | * 33 | * @type {Callback} 34 | */ 35 | this.onDidCancel = onDidCancel; 36 | 37 | /** 38 | * Settings of how to generate new method that will be passed to the parser 39 | * 40 | * @type {Object} 41 | */ 42 | this.settings = { 43 | generateDocs: true, 44 | methodName: '', 45 | visibility: 'private', 46 | tabs: false, 47 | arraySyntax: 'brackets', 48 | typeHinting: true, 49 | generateDescPlaceholders: false 50 | }; 51 | 52 | /** 53 | * Builder to use when generating preview area 54 | * 55 | * @type {Builder} 56 | */ 57 | this.builder = null; 58 | } 59 | 60 | /** 61 | * Content to be displayed when this view is shown. 62 | */ 63 | static content() { 64 | return this.div({class: 'php-ide-serenata-refactoring-extract-method'}, () => { 65 | this.div({outlet: 'methodNameForm'}, () => { 66 | this.subview('methodNameEditor', new TextEditorView( 67 | { mini: true, placeholderText: 'Enter a method name' } 68 | )); 69 | this.div( 70 | { class: 'text-error error-message hide error-message--method-name' }, 71 | 'You must enter a method name!' 72 | ); 73 | return this.div({class: 'settings-view'}, () => { 74 | return this.div({class: 'section-body'}, () => { 75 | this.div({class: 'control-group'}, () => { 76 | return this.div({class: 'controls'}, () => { 77 | return this.label({class: 'control-label'}, () => { 78 | this.div({class: 'setting-title'}, 'Access Modifier'); 79 | return this.select({ 80 | outlet: 'accessMethodsInput', 81 | class: 'form-control' 82 | }, () => { 83 | this.option({value: 'public'}, 'Public'); 84 | this.option({value: 'protected'}, 'Protected'); 85 | return this.option({value: 'private', selected: 'selected'}, 'Private'); 86 | }); 87 | }); 88 | }); 89 | }); 90 | this.div({class: 'control-group'}, () => { 91 | return this.label({class: 'control-label'}, () => { 92 | this.div({class: 'setting-title'}, 'Documentation'); 93 | this.div({class: 'controls'}, () => { 94 | return this.div({class: 'checkbox'}, () => { 95 | return this.label(() => { 96 | this.input({ 97 | outlet: 'generateDocInput', 98 | type: 'checkbox', 99 | checked: true 100 | }); 101 | return this.div({class: 'setting-title'}, 'Generate documentation'); 102 | }); 103 | }); 104 | }); 105 | return this.div({class: 'controls generate-docs-control'}, () => { 106 | return this.div({class: 'checkbox'}, () => { 107 | return this.label(() => { 108 | this.input({ 109 | outlet: 'generateDescPlaceholdersInput', 110 | type: 'checkbox' 111 | }); 112 | return this.div( 113 | { class: 'setting-title' }, 114 | 'Generate description placeholders' 115 | ); 116 | }); 117 | }); 118 | }); 119 | }); 120 | }); 121 | this.div({class: 'control-group'}, () => { 122 | return this.label({class: 'control-label'}, () => { 123 | this.div({class: 'setting-title'}, 'Type hinting'); 124 | return this.div({class: 'controls'}, () => { 125 | return this.div({class: 'checkbox'}, () => { 126 | return this.label(() => { 127 | this.input({ 128 | outlet: 'generateTypeHints', 129 | type: 'checkbox', 130 | checked: true 131 | }); 132 | return this.div({class: 'setting-title'}, 'Generate type hints'); 133 | }); 134 | }); 135 | }); 136 | }); 137 | }); 138 | this.div({class: 'return-multiple-control control-group'}, () => { 139 | return this.label({class: 'control-label'}, () => { 140 | this.div({class: 'setting-title'}, 'Method styling'); 141 | return this.div({class: 'controls'}, () => { 142 | return this.div({class: 'checkbox'}, () => { 143 | return this.label(() => { 144 | this.input({ 145 | outlet: 'arraySyntax', 146 | type: 'checkbox', 147 | checked: true 148 | }); 149 | return this.div( 150 | { class: 'setting-title' }, 151 | 'Use PHP 5.4 array syntax (square brackets)' 152 | ); 153 | }); 154 | }); 155 | }); 156 | }); 157 | }); 158 | return this.div({class: 'control-group'}, () => { 159 | return this.div({class: 'controls'}, () => { 160 | return this.label({class: 'control-label'}, () => { 161 | this.div({class: 'setting-title'}, 'Preview'); 162 | return this.div({class: 'preview-area'}, () => { 163 | return this.subview( 164 | 'previewArea', 165 | new TextEditorView(), 166 | { class: 'preview-area' } 167 | ); 168 | }); 169 | }); 170 | }); 171 | }); 172 | }); 173 | }); 174 | }); 175 | return this.div({class: 'button-bar'}, () => { 176 | this.button( 177 | { class: 'btn btn-error inline-block-tight pull-left icon icon-circle-slash button--cancel' }, 178 | 'Cancel' 179 | ); 180 | this.button( 181 | { class: 'btn btn-success inline-block-tight pull-right icon icon-gear button--confirm' }, 182 | 'Extract' 183 | ); 184 | return this.div({class: 'clear-float'}); 185 | }); 186 | }); 187 | } 188 | 189 | /** 190 | * @inheritdoc 191 | */ 192 | initialize() { 193 | atom.commands.add(this.element, { 194 | 'core:confirm': event => { 195 | this.confirm(); 196 | return event.stopPropagation(); 197 | }, 198 | 'core:cancel': event => { 199 | this.cancel(); 200 | return event.stopPropagation(); 201 | } 202 | } 203 | ); 204 | 205 | this.on('click', 'button', event => { 206 | if ($(event.target).hasClass('button--confirm')) { this.confirm(); } 207 | if ($(event.target).hasClass('button--cancel')) { return this.cancel(); } 208 | }); 209 | 210 | this.methodNameEditor.getModel().onDidChange(() => { 211 | this.settings.methodName = this.methodNameEditor.getText(); 212 | $('.php-ide-serenata-refactoring-extract-method .error-message--method-name').addClass('hide'); 213 | 214 | return this.refreshPreviewArea(); 215 | }); 216 | 217 | $(this.accessMethodsInput[0]).change(event => { 218 | this.settings.visibility = $(event.target).val(); 219 | return this.refreshPreviewArea(); 220 | }); 221 | 222 | $(this.generateDocInput[0]).change(() => { 223 | this.settings.generateDocs = !this.settings.generateDocs; 224 | if (this.settings.generateDocs === true) { 225 | $('.php-ide-serenata-refactoring-extract-method .generate-docs-control').removeClass('hide'); 226 | } else { 227 | $('.php-ide-serenata-refactoring-extract-method .generate-docs-control').addClass('hide'); 228 | } 229 | 230 | 231 | return this.refreshPreviewArea(); 232 | }); 233 | 234 | $(this.generateDescPlaceholdersInput[0]).change(() => { 235 | this.settings.generateDescPlaceholders = !this.settings.generateDescPlaceholders; 236 | return this.refreshPreviewArea(); 237 | }); 238 | 239 | $(this.generateTypeHints[0]).change(() => { 240 | this.settings.typeHinting = !this.settings.typeHinting; 241 | return this.refreshPreviewArea(); 242 | }); 243 | 244 | $(this.arraySyntax[0]).change(() => { 245 | if (this.settings.arraySyntax === 'word') { 246 | this.settings.arraySyntax = 'brackets'; 247 | } else { 248 | this.settings.arraySyntax = 'word'; 249 | } 250 | return this.refreshPreviewArea(); 251 | }); 252 | 253 | if (this.panel == null) { this.panel = atom.workspace.addModalPanel({item: this, visible: false}); } 254 | 255 | const previewAreaTextEditor = this.previewArea.getModel(); 256 | previewAreaTextEditor.setGrammar(atom.grammars.grammarForScopeName('text.html.php')); 257 | 258 | this.on('click', document, event => { 259 | return event.stopPropagation(); 260 | }); 261 | 262 | return $(document).on('click', () => { 263 | if (this.panel != null ? this.panel.isVisible() : undefined) { 264 | return this.cancel(); 265 | } 266 | }); 267 | } 268 | 269 | /** 270 | * Destroys the view and cleans up. 271 | */ 272 | destroy() { 273 | this.panel.destroy(); 274 | this.panel = null; 275 | } 276 | 277 | /** 278 | * Shows the view and refreshes the preview area with the current settings. 279 | */ 280 | present() { 281 | this.panel.show(); 282 | this.methodNameEditor.focus(); 283 | return this.methodNameEditor.setText(''); 284 | } 285 | 286 | /** 287 | * Hides the panel. 288 | */ 289 | hide() { 290 | if (this.panel != null) { 291 | this.panel.hide(); 292 | } 293 | return this.restoreFocus(); 294 | } 295 | 296 | /** 297 | * Called when the user confirms the extraction and will then call 298 | * onDidConfirm, if set. 299 | */ 300 | confirm() { 301 | if (this.settings.methodName === '') { 302 | $('.php-ide-serenata-refactoring-extract-method .error-message--method-name').removeClass('hide'); 303 | return false; 304 | } 305 | 306 | if (this.onDidConfirm) { 307 | this.onDidConfirm(this.getSettings()); 308 | } 309 | 310 | return this.hide(); 311 | } 312 | 313 | /** 314 | * Called when the user cancels the extraction and will then call 315 | * onDidCancel, if set. 316 | */ 317 | cancel() { 318 | if (this.onDidCancel) { 319 | this.onDidCancel(); 320 | } 321 | 322 | return this.hide(); 323 | } 324 | 325 | /** 326 | * Updates the preview area using the current setttings. 327 | */ 328 | refreshPreviewArea() { 329 | if (!this.panel.isVisible()) { return; } 330 | 331 | const successHandler = methodBody => { 332 | if (this.builder.hasReturnValues()) { 333 | if (this.builder.hasMultipleReturnValues()) { 334 | $('.php-ide-serenata-refactoring-extract-method .return-multiple-control').removeClass('hide'); 335 | } else { 336 | $('.php-ide-serenata-refactoring-extract-method .return-multiple-control').addClass('hide'); 337 | } 338 | 339 | $('.php-ide-serenata-refactoring-extract-method .return-control').removeClass('hide'); 340 | } else { 341 | $('.php-ide-serenata-refactoring-extract-method .return-control').addClass('hide'); 342 | $('.php-ide-serenata-refactoring-extract-method .return-multiple-control').addClass('hide'); 343 | } 344 | 345 | return this.previewArea.getModel().getBuffer().setText(` { 53 | return this.executeCommand(true, false); 54 | } 55 | } 56 | ); 57 | 58 | atom.commands.add('atom-workspace', { 'php-ide-serenata-refactoring:generate-setter': () => { 59 | return this.executeCommand(false, true); 60 | } 61 | } 62 | ); 63 | 64 | return atom.commands.add( 65 | 'atom-workspace', 66 | { 'php-ide-serenata-refactoring:generate-getter-setter-pair': () => { 67 | return this.executeCommand(true, true); 68 | } } 69 | ); 70 | } 71 | 72 | /** 73 | * @inheritdoc 74 | */ 75 | deactivate() { 76 | super.deactivate(); 77 | 78 | if (this.selectionView) { 79 | this.selectionView.destroy(); 80 | this.selectionView = null; 81 | } 82 | } 83 | 84 | /** 85 | * @inheritdoc 86 | */ 87 | getIntentionProviders() { 88 | return [{ 89 | grammarScopes: ['source.php'], 90 | getIntentions: () => { 91 | const successHandler = currentClassName => { 92 | if (!currentClassName) { return []; } 93 | 94 | return [ 95 | { 96 | priority : 100, 97 | icon : 'gear', 98 | title : 'Generate Getter And Setter Pair(s)', 99 | 100 | selected : () => { 101 | return this.executeCommand(true, true); 102 | } 103 | }, 104 | 105 | { 106 | priority : 100, 107 | icon : 'gear', 108 | title : 'Generate Getter(s)', 109 | 110 | selected : () => { 111 | return this.executeCommand(true, false); 112 | } 113 | }, 114 | 115 | { 116 | priority : 100, 117 | icon : 'gear', 118 | title : 'Generate Setter(s)', 119 | 120 | selected : () => { 121 | return this.executeCommand(false, true); 122 | } 123 | } 124 | ]; 125 | }; 126 | 127 | const failureHandler = () => []; 128 | 129 | const activeTextEditor = atom.workspace.getActiveTextEditor(); 130 | 131 | if (!activeTextEditor) { return []; } 132 | if ((this.getCurrentProjectPhpVersion() == null)) { return []; } 133 | 134 | return this.service.determineCurrentClassName( 135 | activeTextEditor, 136 | activeTextEditor.getCursorBufferPosition() 137 | ).then(successHandler, failureHandler); 138 | } 139 | }]; 140 | } 141 | 142 | /** 143 | * Executes the generation. 144 | * 145 | * @param {boolean} enableGetterGeneration 146 | * @param {boolean} enableSetterGeneration 147 | */ 148 | executeCommand(enableGetterGeneration, enableSetterGeneration) { 149 | const activeTextEditor = atom.workspace.getActiveTextEditor(); 150 | 151 | if (!activeTextEditor) { return; } 152 | 153 | this.getSelectionView().setMetadata({editor: activeTextEditor}); 154 | this.getSelectionView().storeFocusedElement(); 155 | this.getSelectionView().present(); 156 | 157 | const successHandler = currentClassName => { 158 | if (!currentClassName) { return; } 159 | 160 | const nestedSuccessHandler = classInfo => { 161 | const enabledItems = []; 162 | const disabledItems = []; 163 | 164 | const indentationLevel = activeTextEditor.indentationForBufferRow( 165 | activeTextEditor.getCursorBufferPosition().row 166 | ); 167 | 168 | for (let name in classInfo.properties) { 169 | const property = classInfo.properties[name]; 170 | const getterName = `get${name.substr(0, 1).toUpperCase()}${name.substr(1)}`; 171 | const setterName = `set${name.substr(0, 1).toUpperCase()}${name.substr(1)}`; 172 | 173 | const getterExists = getterName in classInfo.methods ? true : false; 174 | const setterExists = setterName in classInfo.methods ? true : false; 175 | 176 | const data = { 177 | name, 178 | types : property.types, 179 | needsGetter : enableGetterGeneration, 180 | needsSetter : enableSetterGeneration, 181 | getterName, 182 | setterName, 183 | tabText : activeTextEditor.getTabText(), 184 | indentationLevel, 185 | maxLineLength : atom.config.get( 186 | 'editor.preferredLineLength', 187 | activeTextEditor.getLastCursor().getScopeDescriptor() 188 | ) 189 | }; 190 | 191 | if ((enableGetterGeneration && enableSetterGeneration && getterExists && setterExists) || 192 | (enableGetterGeneration && getterExists) || 193 | (enableSetterGeneration && setterExists)) { 194 | data.className = 'php-ide-serenata-refactoring-strikethrough'; 195 | disabledItems.push(data); 196 | 197 | } else { 198 | data.className = ''; 199 | enabledItems.push(data); 200 | } 201 | } 202 | 203 | return this.getSelectionView().setItems(enabledItems.concat(disabledItems)); 204 | }; 205 | 206 | const nestedFailureHandler = () => { 207 | return this.getSelectionView().setItems([]); 208 | }; 209 | 210 | return this.service.getClassInfo(currentClassName).then(nestedSuccessHandler, nestedFailureHandler); 211 | }; 212 | 213 | const failureHandler = () => { 214 | return this.getSelectionView().setItems([]); 215 | }; 216 | 217 | return this.service.determineCurrentClassName(activeTextEditor, activeTextEditor.getCursorBufferPosition()) 218 | .then(successHandler, failureHandler); 219 | } 220 | 221 | /** 222 | * Indicates if the specified type is a class type or not. 223 | * 224 | * @return {bool} 225 | */ 226 | isClassType(type) { 227 | if (type.substr(0, 1).toUpperCase() === type.substr(0, 1)) { return true; } else { return false; } 228 | } 229 | 230 | /** 231 | * Called when the selection of properties is cancelled. 232 | * 233 | * @param {Object|null} metadata 234 | */ 235 | onCancel() {} 236 | 237 | /** 238 | * Called when the selection of properties is confirmed. 239 | * 240 | * @param {array} selectedItems 241 | * @param {Object|null} metadata 242 | */ 243 | async onConfirm(selectedItems, metadata) { 244 | const itemOutputs = []; 245 | const editorUri = 'file://' + metadata.editor.getPath(); 246 | const bufferPosition = metadata.editor.getCursorBufferPosition(); 247 | 248 | for (const item of selectedItems) { 249 | if (item.needsGetter) { 250 | itemOutputs.push(await this.generateGetterForItem(item, editorUri, bufferPosition)); 251 | } 252 | 253 | if (item.needsSetter) { 254 | itemOutputs.push(await this.generateSetterForItem(item, editorUri, bufferPosition)); 255 | } 256 | } 257 | 258 | const output = itemOutputs.join('\n').trim(); 259 | 260 | return metadata.editor.getBuffer().insert(bufferPosition, output); 261 | } 262 | 263 | /** 264 | * Generates a getter for the specified selected item. 265 | * 266 | * @param {Object} item 267 | * @param {String} editorUri 268 | * @param {Point} bufferPosition 269 | * 270 | * @return {string} 271 | */ 272 | async generateGetterForItem(item, editorUri, bufferPosition) { 273 | await this.localizeFunctionParameterTypes(item, editorUri, bufferPosition); 274 | 275 | const typeSpecification = this.typeHelper.buildTypeSpecificationFromTypeArray(item.types); 276 | 277 | const statements = [ 278 | `return $this->${item.name};` 279 | ]; 280 | 281 | const functionText = this.functionBuilder 282 | .makePublic() 283 | .setIsStatic(false) 284 | .setIsAbstract(false) 285 | .setName(item.getterName) 286 | .setReturnType(this.typeHelper.getReturnTypeHintForTypeSpecification(typeSpecification)) 287 | .setParameters([]) 288 | .setStatements(statements) 289 | .setTabText(item.tabText) 290 | .setIndentationLevel(item.indentationLevel) 291 | .setMaxLineLength(item.maxLineLength) 292 | .build(); 293 | 294 | const docblockText = this.docblockBuilder.buildForMethod( 295 | [], 296 | typeSpecification, 297 | false, 298 | item.tabText.repeat(item.indentationLevel) 299 | ); 300 | 301 | return docblockText + functionText; 302 | } 303 | 304 | /** 305 | * Generates a setter for the specified selected item. 306 | * 307 | * @param {Object} item 308 | * 309 | * @return {string} 310 | */ 311 | generateSetterForItem(item) { 312 | const typeSpecification = this.typeHelper.buildTypeSpecificationFromTypeArray(item.types); 313 | const parameterTypeHint = this.typeHelper.getTypeHintForTypeSpecification(typeSpecification); 314 | 315 | const statements = [ 316 | `$this->${item.name} = $${item.name};`, 317 | 'return $this;' 318 | ]; 319 | 320 | const parameters = [ 321 | { 322 | name : `$${item.name}`, 323 | typeHint : parameterTypeHint.typeHint, 324 | defaultValue : parameterTypeHint.shouldSetDefaultValueToNull ? 'null' : null 325 | } 326 | ]; 327 | 328 | const functionText = this.functionBuilder 329 | .makePublic() 330 | .setIsStatic(false) 331 | .setIsAbstract(false) 332 | .setName(item.setterName) 333 | .setReturnType(null) 334 | .setParameters(parameters) 335 | .setStatements(statements) 336 | .setTabText(item.tabText) 337 | .setIndentationLevel(item.indentationLevel) 338 | .setMaxLineLength(item.maxLineLength) 339 | .build(); 340 | 341 | const docblockText = this.docblockBuilder.buildForMethod( 342 | [{name : `$${item.name}`, type : typeSpecification}], 343 | 'static', 344 | false, 345 | item.tabText.repeat(item.indentationLevel) 346 | ); 347 | 348 | return docblockText + functionText; 349 | } 350 | 351 | /** 352 | * @return {Builder} 353 | */ 354 | getSelectionView() { 355 | if ((this.selectionView == null)) { 356 | const View = require('./GetterSetterProvider/View'); 357 | 358 | this.selectionView = new View(this.onConfirm.bind(this), this.onCancel.bind(this)); 359 | this.selectionView.setLoading('Loading class information...'); 360 | this.selectionView.setEmptyMessage('No properties found.'); 361 | } 362 | 363 | return this.selectionView; 364 | } 365 | }; 366 | -------------------------------------------------------------------------------- /lib/Refactoring/GetterSetterProvider/View.js: -------------------------------------------------------------------------------- 1 | const MultiSelectionView = require('../Utility/MultiSelectionView'); 2 | 3 | module.exports = 4 | 5 | class View extends MultiSelectionView {}; 6 | -------------------------------------------------------------------------------- /lib/Refactoring/IntroducePropertyProvider.js: -------------------------------------------------------------------------------- 1 | /* 2 | * decaffeinate suggestions: 3 | * DS102: Remove unnecessary code created because of implicit returns 4 | * DS207: Consider shorter variations of null checks 5 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 6 | */ 7 | const {Point} = require('atom'); 8 | 9 | const AbstractProvider = require('./AbstractProvider'); 10 | 11 | module.exports = 12 | 13 | //#* 14 | // Provides property generation for non-existent properties. 15 | //# 16 | class IntroducePropertyProvider extends AbstractProvider { 17 | /** 18 | * @param {Object} docblockBuilder 19 | */ 20 | constructor(docblockBuilder) { 21 | super(); 22 | 23 | /** 24 | * The docblock builder. 25 | */ 26 | this.docblockBuilder = docblockBuilder; 27 | } 28 | 29 | /** 30 | * @inheritdoc 31 | */ 32 | getIntentionProviders() { 33 | return [{ 34 | grammarScopes: ['variable.other.property.php'], 35 | getIntentions: ({textEditor, bufferPosition}) => { 36 | const nameRange = textEditor.bufferRangeForScopeAtCursor('variable.other.property'); 37 | 38 | if ((nameRange == null)) { return []; } 39 | if ((this.getCurrentProjectPhpVersion() == null)) { return []; } 40 | 41 | const name = textEditor.getTextInBufferRange(nameRange); 42 | 43 | return this.getIntentions(textEditor, bufferPosition, name); 44 | } 45 | }]; 46 | } 47 | 48 | /** 49 | * @param {TextEditor} editor 50 | * @param {Point} triggerPosition 51 | * @param {String} name 52 | */ 53 | getIntentions(editor, triggerPosition, name) { 54 | const failureHandler = () => { 55 | return []; 56 | }; 57 | 58 | const successHandler = currentClassName => { 59 | if ((currentClassName == null)) { return []; } 60 | 61 | const nestedSuccessHandler = classInfo => { 62 | const intentions = []; 63 | 64 | if (!classInfo) { return intentions; } 65 | 66 | if (!(name in classInfo.properties)) { 67 | intentions.push({ 68 | priority : 100, 69 | icon : 'gear', 70 | title : 'Introduce New Property', 71 | 72 | selected : () => { 73 | return this.introducePropertyFor(editor, classInfo, name); 74 | } 75 | }); 76 | } 77 | 78 | return intentions; 79 | }; 80 | 81 | return this.service.getClassInfo(currentClassName).then(nestedSuccessHandler, failureHandler); 82 | }; 83 | 84 | return this.service.determineCurrentClassName(editor, triggerPosition).then(successHandler, failureHandler); 85 | } 86 | 87 | /** 88 | * @param {TextEditor} editor 89 | * @param {Object} classData 90 | * @param {String} name 91 | */ 92 | introducePropertyFor(editor, classData, name) { 93 | const indentationLevel = editor.indentationForBufferRow(classData.range.start.line) + 1; 94 | 95 | const tabText = editor.getTabText().repeat(indentationLevel); 96 | 97 | const docblock = this.docblockBuilder.buildForProperty( 98 | 'mixed', 99 | false, 100 | tabText 101 | ); 102 | 103 | const property = `${tabText}protected $${name};\n\n`; 104 | 105 | const point = this.findLocationToInsertProperty(editor, classData); 106 | 107 | return editor.getBuffer().insert(point, docblock + property); 108 | } 109 | 110 | 111 | /** 112 | * @param {TextEditor} editor 113 | * @param {Object} classData 114 | * 115 | * @return {Point} 116 | */ 117 | findLocationToInsertProperty(editor, classData) { 118 | let startLine = null; 119 | 120 | // Try to place the new property underneath the existing properties. 121 | for (let name in classData.properties) { 122 | const propertyData = classData.properties[name]; 123 | if (propertyData.declaringStructure.name === classData.name) { 124 | startLine = propertyData.range.end.line + 2; 125 | } 126 | } 127 | 128 | if ((startLine == null)) { 129 | // Ensure we don't end up somewhere in the middle of the class definition if it spans multiple lines. 130 | const lineCount = editor.getLineCount(); 131 | 132 | for ( 133 | let line = (classData.range.start.line + 1), 134 | end = lineCount, 135 | asc = (classData.range.start.line + 1) <= end; 136 | asc ? line <= end : line >= end; 137 | asc ? line++ : line-- 138 | ) { 139 | const lineText = editor.lineTextForBufferRow(line); 140 | 141 | if ((lineText == null)) { continue; } 142 | 143 | for ( 144 | let i = 0, end1 = lineText.length - 1, asc1 = 0 <= end1; 145 | asc1 ? i <= end1 : i >= end1; 146 | asc1 ? i++ : i-- 147 | ) { 148 | if (lineText[i] === '{') { 149 | startLine = line + 1; 150 | break; 151 | } 152 | } 153 | 154 | if (startLine != null) { break; } 155 | } 156 | } 157 | 158 | if ((startLine == null)) { 159 | startLine = classData.range.start.line + 2; 160 | } 161 | 162 | return new Point(startLine, -1); 163 | } 164 | }; 165 | -------------------------------------------------------------------------------- /lib/Refactoring/OverrideMethodProvider.js: -------------------------------------------------------------------------------- 1 | /* global atom */ 2 | 3 | /* 4 | * decaffeinate suggestions: 5 | * DS102: Remove unnecessary code created because of implicit returns 6 | * DS207: Consider shorter variations of null checks 7 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 8 | */ 9 | const AbstractProvider = require('./AbstractProvider'); 10 | 11 | module.exports = 12 | 13 | //#* 14 | // Provides the ability to implement interface methods. 15 | //# 16 | class OverrideMethodProvider extends AbstractProvider { 17 | /** 18 | * @param {Object} docblockBuilder 19 | * @param {Object} functionBuilder 20 | */ 21 | constructor(docblockBuilder, functionBuilder) { 22 | super(); 23 | 24 | /** 25 | * The view that allows the user to select the properties to generate for. 26 | */ 27 | this.selectionView = null; 28 | 29 | /** 30 | * @type {Object} 31 | */ 32 | this.docblockBuilder = docblockBuilder; 33 | 34 | /** 35 | * @type {Object} 36 | */ 37 | this.functionBuilder = functionBuilder; 38 | } 39 | 40 | /** 41 | * @inheritdoc 42 | */ 43 | deactivate() { 44 | super.deactivate(); 45 | 46 | if (this.selectionView) { 47 | this.selectionView.destroy(); 48 | this.selectionView = null; 49 | } 50 | } 51 | 52 | /** 53 | * @inheritdoc 54 | */ 55 | getIntentionProviders() { 56 | return [{ 57 | grammarScopes: ['source.php'], 58 | getIntentions: ({textEditor, bufferPosition}) => { 59 | if ((this.getCurrentProjectPhpVersion() == null)) { return []; } 60 | 61 | return this.getStubInterfaceMethodIntentions(textEditor, bufferPosition); 62 | } 63 | }]; 64 | } 65 | 66 | /** 67 | * @param {TextEditor} editor 68 | * @param {Point} triggerPosition 69 | */ 70 | getStubInterfaceMethodIntentions(editor, triggerPosition) { 71 | const failureHandler = () => []; 72 | 73 | const successHandler = currentClassName => { 74 | if (!currentClassName) { return []; } 75 | 76 | const nestedSuccessHandler = classInfo => { 77 | if (!classInfo) { return []; } 78 | 79 | const items = []; 80 | 81 | for (let name in classInfo.methods) { 82 | const method = classInfo.methods[name]; 83 | const data = { 84 | name, 85 | method 86 | }; 87 | 88 | // Interface methods can already be stubbed via StubInterfaceMethodProvider. 89 | if (method.declaringStructure.type === 'interface') { continue; } 90 | 91 | // Abstract methods can already be stubbed via StubAbstractMethodProvider. 92 | if (method.isAbstract) { continue; } 93 | 94 | if (method.declaringStructure.name !== classInfo.name) { 95 | items.push(data); 96 | } 97 | } 98 | 99 | if (items.length === 0) { return []; } 100 | 101 | this.getSelectionView().setItems(items); 102 | 103 | return [ 104 | { 105 | priority : 100, 106 | icon : 'link', 107 | title : 'Override Method(s)', 108 | 109 | selected : () => { 110 | return this.executeStubInterfaceMethods(editor); 111 | } 112 | } 113 | ]; 114 | }; 115 | 116 | return this.service.getClassInfo(currentClassName).then(nestedSuccessHandler, failureHandler); 117 | }; 118 | 119 | return this.service.determineCurrentClassName(editor, triggerPosition).then(successHandler, failureHandler); 120 | } 121 | 122 | /** 123 | * @param {TextEditor} editor 124 | * @param {Point} triggerPosition 125 | */ 126 | executeStubInterfaceMethods(editor) { 127 | this.getSelectionView().setMetadata({editor}); 128 | this.getSelectionView().storeFocusedElement(); 129 | return this.getSelectionView().present(); 130 | } 131 | 132 | /** 133 | * Called when the selection of properties is cancelled. 134 | */ 135 | onCancel() {} 136 | 137 | /** 138 | * Called when the selection of properties is confirmed. 139 | * 140 | * @param {array} selectedItems 141 | * @param {Object|null} metadata 142 | */ 143 | async onConfirm(selectedItems, metadata) { 144 | const itemOutputs = []; 145 | 146 | const tabText = metadata.editor.getTabText(); 147 | const editorUri = 'file://' + metadata.editor.getPath(); 148 | const bufferPosition = metadata.editor.getCursorBufferPosition(); 149 | const indentationLevel = metadata.editor.indentationForBufferRow(bufferPosition.row); 150 | const maxLineLength = atom.config.get( 151 | 'editor.preferredLineLength', 152 | metadata.editor.getLastCursor().getScopeDescriptor() 153 | ); 154 | 155 | for (const item of selectedItems) { 156 | const stub = await this.generateStubForInterfaceMethod( 157 | item.method, 158 | tabText, 159 | indentationLevel, 160 | maxLineLength, 161 | editorUri, 162 | bufferPosition 163 | ); 164 | 165 | itemOutputs.push(stub); 166 | } 167 | 168 | const output = itemOutputs.join('\n').trim(); 169 | 170 | return metadata.editor.insertText(output); 171 | } 172 | 173 | /** 174 | * Generates an override for the specified selected data. 175 | * 176 | * @param {Object} data 177 | * @param {String} tabText 178 | * @param {Number} indentationLevel 179 | * @param {Number} maxLineLength 180 | * @param {String} editorUri 181 | * @param {Position} bufferPosition 182 | * 183 | * @return {Promise} 184 | */ 185 | async generateStubForInterfaceMethod( 186 | data, 187 | tabText, 188 | indentationLevel, 189 | maxLineLength, 190 | editorUri, 191 | bufferPosition 192 | ) { 193 | const parameterNames = data.parameters.map(item => `$${item.name}`); 194 | 195 | const hasReturnValue = this.hasReturnValue(data); 196 | 197 | let parentCallStatement = ''; 198 | 199 | if (hasReturnValue) { 200 | parentCallStatement += '$value = '; 201 | } 202 | 203 | parentCallStatement += `parent::${data.name}(`; 204 | parentCallStatement += parameterNames.join(', '); 205 | parentCallStatement += ');'; 206 | 207 | const statements = [ 208 | parentCallStatement, 209 | '', 210 | '// TODO' 211 | ]; 212 | 213 | if (hasReturnValue) { 214 | statements.push(''); 215 | statements.push('return $value;'); 216 | } 217 | 218 | await this.localizeFunctionParameterTypeHints(data.parameters, editorUri, bufferPosition); 219 | 220 | const functionText = this.functionBuilder 221 | .setFromRawMethodData(data) 222 | .setIsAbstract(false) 223 | .setStatements(statements) 224 | .setTabText(tabText) 225 | .setIndentationLevel(indentationLevel) 226 | .setMaxLineLength(maxLineLength) 227 | .build(); 228 | 229 | const docblockText = this.docblockBuilder.buildByLines(['@inheritDoc'], tabText.repeat(indentationLevel)); 230 | 231 | return docblockText + functionText; 232 | } 233 | 234 | /** 235 | * @param {Object} data 236 | * 237 | * @return {Boolean} 238 | */ 239 | hasReturnValue(data) { 240 | if (data.name === '__construct') { return false; } 241 | if (data.returnTypes.length === 0) { return false; } 242 | if ((data.returnTypes.length === 1) && (data.returnTypes[0].type === 'void')) { return false; } 243 | 244 | return true; 245 | } 246 | 247 | /** 248 | * @return {Builder} 249 | */ 250 | getSelectionView() { 251 | if ((this.selectionView == null)) { 252 | const View = require('./OverrideMethodProvider/View'); 253 | 254 | this.selectionView = new View(this.onConfirm.bind(this), this.onCancel.bind(this)); 255 | this.selectionView.setLoading('Loading class information...'); 256 | this.selectionView.setEmptyMessage('No overridable methods found.'); 257 | } 258 | 259 | return this.selectionView; 260 | } 261 | }; 262 | -------------------------------------------------------------------------------- /lib/Refactoring/OverrideMethodProvider/View.js: -------------------------------------------------------------------------------- 1 | const MultiSelectionView = require('../Utility/MultiSelectionView'); 2 | 3 | module.exports = 4 | 5 | class View extends MultiSelectionView {}; 6 | -------------------------------------------------------------------------------- /lib/Refactoring/StubAbstractMethodProvider.js: -------------------------------------------------------------------------------- 1 | /* global atom */ 2 | 3 | /* 4 | * decaffeinate suggestions: 5 | * DS102: Remove unnecessary code created because of implicit returns 6 | * DS207: Consider shorter variations of null checks 7 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 8 | */ 9 | const AbstractProvider = require('./AbstractProvider'); 10 | 11 | module.exports = 12 | 13 | //#* 14 | // Provides the ability to stub abstract methods. 15 | //# 16 | class StubAbstractMethodProvider extends AbstractProvider { 17 | /** 18 | * @param {Object} docblockBuilder 19 | * @param {Object} functionBuilder 20 | */ 21 | constructor(docblockBuilder, functionBuilder) { 22 | super(); 23 | 24 | /** 25 | * The view that allows the user to select the properties to generate for. 26 | */ 27 | this.selectionView = null; 28 | 29 | /** 30 | * @type {Object} 31 | */ 32 | this.docblockBuilder = docblockBuilder; 33 | 34 | /** 35 | * @type {Object} 36 | */ 37 | this.functionBuilder = functionBuilder; 38 | } 39 | 40 | /** 41 | * @inheritdoc 42 | */ 43 | deactivate() { 44 | super.deactivate(); 45 | 46 | if (this.selectionView) { 47 | this.selectionView.destroy(); 48 | this.selectionView = null; 49 | } 50 | } 51 | 52 | /** 53 | * @inheritdoc 54 | */ 55 | getIntentionProviders() { 56 | return [{ 57 | grammarScopes: ['source.php'], 58 | getIntentions: ({textEditor, bufferPosition}) => { 59 | if ((this.getCurrentProjectPhpVersion() == null)) { return []; } 60 | 61 | return this.getStubInterfaceMethodIntentions(textEditor, bufferPosition); 62 | } 63 | }]; 64 | } 65 | 66 | /** 67 | * @param {TextEditor} editor 68 | * @param {Point} triggerPosition 69 | */ 70 | getStubInterfaceMethodIntentions(editor, triggerPosition) { 71 | const failureHandler = () => []; 72 | 73 | const successHandler = currentClassName => { 74 | if (!currentClassName) { return []; } 75 | 76 | const nestedSuccessHandler = classInfo => { 77 | if (!classInfo) { return []; } 78 | 79 | const items = []; 80 | 81 | for (let name in classInfo.methods) { 82 | const method = classInfo.methods[name]; 83 | const data = { 84 | name, 85 | method 86 | }; 87 | 88 | if (method.isAbstract) { 89 | items.push(data); 90 | } 91 | } 92 | 93 | if (items.length === 0) { return []; } 94 | 95 | this.getSelectionView().setItems(items); 96 | 97 | return [ 98 | { 99 | priority : 100, 100 | icon : 'link', 101 | title : 'Stub Unimplemented Abstract Method(s)', 102 | 103 | selected : () => { 104 | return this.executeStubInterfaceMethods(editor); 105 | } 106 | } 107 | ]; 108 | }; 109 | 110 | return this.service.getClassInfo(currentClassName).then(nestedSuccessHandler, failureHandler); 111 | }; 112 | 113 | return this.service.determineCurrentClassName(editor, triggerPosition).then(successHandler, failureHandler); 114 | } 115 | 116 | /** 117 | * @param {TextEditor} editor 118 | * @param {Point} triggerPosition 119 | */ 120 | executeStubInterfaceMethods(editor) { 121 | this.getSelectionView().setMetadata({editor}); 122 | this.getSelectionView().storeFocusedElement(); 123 | return this.getSelectionView().present(); 124 | } 125 | 126 | /** 127 | * Called when the selection of properties is cancelled. 128 | */ 129 | onCancel() {} 130 | 131 | /** 132 | * Called when the selection of properties is confirmed. 133 | * 134 | * @param {array} selectedItems 135 | * @param {Object|null} metadata 136 | */ 137 | async onConfirm(selectedItems, metadata) { 138 | const itemOutputs = []; 139 | 140 | const tabText = metadata.editor.getTabText(); 141 | const editorUri = 'file://' + metadata.editor.getPath(); 142 | const bufferPosition = metadata.editor.getCursorBufferPosition(); 143 | const indentationLevel = metadata.editor.indentationForBufferRow(bufferPosition.row); 144 | const maxLineLength = atom.config.get( 145 | 'editor.preferredLineLength', 146 | metadata.editor.getLastCursor().getScopeDescriptor() 147 | ); 148 | 149 | for (const item of selectedItems) { 150 | const stub = await this.generateStubForInterfaceMethod( 151 | item.method, 152 | tabText, 153 | indentationLevel, 154 | maxLineLength, 155 | editorUri, 156 | bufferPosition 157 | ); 158 | 159 | itemOutputs.push(stub); 160 | } 161 | 162 | const output = itemOutputs.join('\n').trim(); 163 | 164 | return metadata.editor.insertText(output); 165 | } 166 | 167 | /** 168 | * Generates a stub for the specified selected data. 169 | * 170 | * @param {Object} data 171 | * @param {String} tabText 172 | * @param {Number} indentationLevel 173 | * @param {Number} maxLineLength 174 | * @param {String} editorUri 175 | * @param {Position} bufferPosition 176 | * 177 | * @return {string} 178 | */ 179 | async generateStubForInterfaceMethod( 180 | data, 181 | tabText, 182 | indentationLevel, 183 | maxLineLength, 184 | editorUri, 185 | bufferPosition 186 | ) { 187 | const statements = [ 188 | 'throw new \\LogicException(\'Not implemented\'); // TODO' 189 | ]; 190 | 191 | await this.localizeFunctionParameterTypeHints(data.parameters, editorUri, bufferPosition); 192 | 193 | const functionText = this.functionBuilder 194 | .setFromRawMethodData(data) 195 | .setIsAbstract(false) 196 | .setStatements(statements) 197 | .setTabText(tabText) 198 | .setIndentationLevel(indentationLevel) 199 | .setMaxLineLength(maxLineLength) 200 | .build(); 201 | 202 | const docblockText = this.docblockBuilder.buildByLines(['@inheritDoc'], tabText.repeat(indentationLevel)); 203 | 204 | return docblockText + functionText; 205 | } 206 | 207 | /** 208 | * @return {Builder} 209 | */ 210 | getSelectionView() { 211 | if ((this.selectionView == null)) { 212 | const View = require('./StubAbstractMethodProvider/View'); 213 | 214 | this.selectionView = new View(this.onConfirm.bind(this), this.onCancel.bind(this)); 215 | this.selectionView.setLoading('Loading class information...'); 216 | this.selectionView.setEmptyMessage('No unimplemented abstract methods found.'); 217 | } 218 | 219 | return this.selectionView; 220 | } 221 | }; 222 | -------------------------------------------------------------------------------- /lib/Refactoring/StubAbstractMethodProvider/View.js: -------------------------------------------------------------------------------- 1 | const MultiSelectionView = require('../Utility/MultiSelectionView'); 2 | 3 | module.exports = 4 | 5 | class View extends MultiSelectionView {}; 6 | -------------------------------------------------------------------------------- /lib/Refactoring/StubInterfaceMethodProvider.js: -------------------------------------------------------------------------------- 1 | /* global atom */ 2 | 3 | /* 4 | * decaffeinate suggestions: 5 | * DS102: Remove unnecessary code created because of implicit returns 6 | * DS207: Consider shorter variations of null checks 7 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 8 | */ 9 | const AbstractProvider = require('./AbstractProvider'); 10 | 11 | module.exports = 12 | 13 | //#* 14 | // Provides the ability to stub interface methods. 15 | //# 16 | class StubInterfaceMethodProvider extends AbstractProvider { 17 | /** 18 | * @param {Object} docblockBuilder 19 | * @param {Object} functionBuilder 20 | */ 21 | constructor(docblockBuilder, functionBuilder) { 22 | super(); 23 | 24 | /** 25 | * The view that allows the user to select the properties to generate for. 26 | */ 27 | this.selectionView = null; 28 | 29 | /** 30 | * @type {Object} 31 | */ 32 | this.docblockBuilder = docblockBuilder; 33 | 34 | /** 35 | * @type {Object} 36 | */ 37 | this.functionBuilder = functionBuilder; 38 | } 39 | 40 | /** 41 | * @inheritdoc 42 | */ 43 | deactivate() { 44 | super.deactivate(); 45 | 46 | if (this.selectionView) { 47 | this.selectionView.destroy(); 48 | this.selectionView = null; 49 | } 50 | } 51 | 52 | /** 53 | * @inheritdoc 54 | */ 55 | getIntentionProviders() { 56 | return [{ 57 | grammarScopes: ['source.php'], 58 | getIntentions: ({textEditor, bufferPosition}) => { 59 | if ((this.getCurrentProjectPhpVersion() == null)) { return []; } 60 | 61 | return this.getStubInterfaceMethodIntentions(textEditor, bufferPosition); 62 | } 63 | }]; 64 | } 65 | 66 | /** 67 | * @param {TextEditor} editor 68 | * @param {Point} triggerPosition 69 | */ 70 | getStubInterfaceMethodIntentions(editor, triggerPosition) { 71 | const failureHandler = () => []; 72 | 73 | const successHandler = currentClassName => { 74 | if (!currentClassName) { return []; } 75 | 76 | const nestedSuccessHandler = classInfo => { 77 | if (!classInfo) { return []; } 78 | 79 | const items = []; 80 | 81 | for (let name in classInfo.methods) { 82 | const method = classInfo.methods[name]; 83 | const data = { 84 | name, 85 | method 86 | }; 87 | 88 | if ( 89 | (method.declaringStructure.type === 'interface') && 90 | ((method.implementations != null ? method.implementations.length : undefined) === 0) 91 | ) { 92 | items.push(data); 93 | } 94 | } 95 | 96 | if (items.length === 0) { return []; } 97 | 98 | this.getSelectionView().setItems(items); 99 | 100 | return [ 101 | { 102 | priority : 100, 103 | icon : 'link', 104 | title : 'Stub Unimplemented Interface Method(s)', 105 | 106 | selected : () => { 107 | return this.executeStubInterfaceMethods(editor); 108 | } 109 | } 110 | ]; 111 | }; 112 | 113 | return this.service.getClassInfo(currentClassName).then(nestedSuccessHandler, failureHandler); 114 | }; 115 | 116 | return this.service.determineCurrentClassName(editor, triggerPosition).then(successHandler, failureHandler); 117 | } 118 | 119 | /** 120 | * @param {TextEditor} editor 121 | * @param {Point} triggerPosition 122 | */ 123 | executeStubInterfaceMethods(editor) { 124 | this.getSelectionView().setMetadata({editor}); 125 | this.getSelectionView().storeFocusedElement(); 126 | return this.getSelectionView().present(); 127 | } 128 | 129 | /** 130 | * Called when the selection of properties is cancelled. 131 | */ 132 | onCancel() {} 133 | 134 | /** 135 | * Called when the selection of properties is confirmed. 136 | * 137 | * @param {array} selectedItems 138 | * @param {Object|null} metadata 139 | */ 140 | async onConfirm(selectedItems, metadata) { 141 | const itemOutputs = []; 142 | 143 | const tabText = metadata.editor.getTabText(); 144 | const editorUri = 'file://' + metadata.editor.getPath(); 145 | const bufferPosition = metadata.editor.getCursorBufferPosition(); 146 | const indentationLevel = metadata.editor.indentationForBufferRow(bufferPosition.row); 147 | const maxLineLength = atom.config.get( 148 | 'editor.preferredLineLength', 149 | metadata.editor.getLastCursor().getScopeDescriptor() 150 | ); 151 | 152 | for (const item of selectedItems) { 153 | const stub = await this.generateStubForInterfaceMethod( 154 | item.method, 155 | tabText, 156 | indentationLevel, 157 | maxLineLength, 158 | editorUri, 159 | bufferPosition 160 | ); 161 | 162 | itemOutputs.push(stub); 163 | } 164 | 165 | const output = itemOutputs.join('\n').trim(); 166 | 167 | return metadata.editor.insertText(output); 168 | } 169 | 170 | /** 171 | * Generates a stub for the specified selected data. 172 | * 173 | * @param {Object} data 174 | * @param {String} tabText 175 | * @param {Number} indentationLevel 176 | * @param {Number} maxLineLength 177 | * @param {String} editorUri 178 | * @param {Position} bufferPosition 179 | * 180 | * @return {string} 181 | */ 182 | async generateStubForInterfaceMethod( 183 | data, 184 | tabText, 185 | indentationLevel, 186 | maxLineLength, 187 | editorUri, 188 | bufferPosition 189 | ) { 190 | const statements = [ 191 | 'throw new \\LogicException(\'Not implemented\'); // TODO' 192 | ]; 193 | 194 | await this.localizeFunctionParameterTypeHints(data.parameters, editorUri, bufferPosition); 195 | 196 | const functionText = this.functionBuilder 197 | .setFromRawMethodData(data) 198 | .setStatements(statements) 199 | .setTabText(tabText) 200 | .setIndentationLevel(indentationLevel) 201 | .setMaxLineLength(maxLineLength) 202 | .build(); 203 | 204 | const docblockText = this.docblockBuilder.buildByLines(['@inheritDoc'], tabText.repeat(indentationLevel)); 205 | 206 | return docblockText + functionText; 207 | } 208 | 209 | /** 210 | * @return {Builder} 211 | */ 212 | getSelectionView() { 213 | if ((this.selectionView == null)) { 214 | const View = require('./StubInterfaceMethodProvider/View'); 215 | 216 | this.selectionView = new View(this.onConfirm.bind(this), this.onCancel.bind(this)); 217 | this.selectionView.setLoading('Loading class information...'); 218 | this.selectionView.setEmptyMessage('No unimplemented interface methods found.'); 219 | } 220 | 221 | return this.selectionView; 222 | } 223 | }; 224 | -------------------------------------------------------------------------------- /lib/Refactoring/StubInterfaceMethodProvider/View.js: -------------------------------------------------------------------------------- 1 | const MultiSelectionView = require('../Utility/MultiSelectionView'); 2 | 3 | module.exports = 4 | 5 | class View extends MultiSelectionView {}; 6 | -------------------------------------------------------------------------------- /lib/Refactoring/Utility/DocblockBuilder.js: -------------------------------------------------------------------------------- 1 | /* 2 | * decaffeinate suggestions: 3 | * DS102: Remove unnecessary code created because of implicit returns 4 | * DS207: Consider shorter variations of null checks 5 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 6 | */ 7 | module.exports = 8 | 9 | class DocblockBuilder { 10 | /** 11 | * @param {Array} parameters 12 | * @param {String|null} returnType 13 | * @param {boolean} generateDescriptionPlaceholders 14 | * @param {String} tabText 15 | * 16 | * @return {String} 17 | */ 18 | buildForMethod(parameters, returnType, generateDescriptionPlaceholders, tabText) { 19 | if (generateDescriptionPlaceholders == null) { generateDescriptionPlaceholders = true; } 20 | if (tabText == null) { tabText = ''; } 21 | const lines = []; 22 | 23 | if (generateDescriptionPlaceholders) { 24 | lines.push('[Short description of the method]'); 25 | } 26 | 27 | if (parameters.length > 0) { 28 | let descriptionPlaceholder = ''; 29 | 30 | if (generateDescriptionPlaceholders) { 31 | lines.push(''); 32 | 33 | descriptionPlaceholder = ' [Description]'; 34 | } 35 | 36 | // Determine the necessary padding. 37 | const parameterTypeLengths = parameters.map(function(item) { 38 | if (item.type) { return item.type.length; } else { return 0; } 39 | }); 40 | 41 | const parameterNameLengths = parameters.map(function(item) { 42 | if (item.name) { return item.name.length; } else { return 0; } 43 | }); 44 | 45 | const longestTypeLength = Math.max(...parameterTypeLengths); 46 | const longestNameLength = Math.max(...parameterNameLengths); 47 | 48 | // Generate parameter lines. 49 | for (const parameter of parameters) { 50 | const typePadding = longestTypeLength - parameter.type.length; 51 | const variablePadding = longestNameLength - parameter.name.length; 52 | 53 | const type = parameter.type + ' '.repeat(typePadding); 54 | const variable = parameter.name + ' '.repeat(variablePadding); 55 | 56 | lines.push(`@param ${type} ${variable}${descriptionPlaceholder}`); 57 | } 58 | } 59 | 60 | if ((returnType != null) && (returnType !== 'void')) { 61 | if (generateDescriptionPlaceholders || (parameters.length > 0)) { 62 | lines.push(''); 63 | } 64 | 65 | lines.push(`@return ${returnType}`); 66 | } 67 | 68 | return this.buildByLines(lines, tabText); 69 | } 70 | 71 | /** 72 | * @param {String|null} type 73 | * @param {boolean} generateDescriptionPlaceholders 74 | * @param {String} tabText 75 | * 76 | * @return {String} 77 | */ 78 | buildForProperty(type, generateDescriptionPlaceholders, tabText) { 79 | if (generateDescriptionPlaceholders == null) { generateDescriptionPlaceholders = true; } 80 | if (tabText == null) { tabText = ''; } 81 | const lines = []; 82 | 83 | if (generateDescriptionPlaceholders) { 84 | lines.push('[Short description of the property]'); 85 | lines.push(''); 86 | } 87 | 88 | lines.push(`@var ${type}`); 89 | 90 | return this.buildByLines(lines, tabText); 91 | } 92 | 93 | /** 94 | * @param {Array} lines 95 | * @param {String} tabText 96 | * 97 | * @return {String} 98 | */ 99 | buildByLines(lines, tabText) { 100 | if (tabText == null) { tabText = ''; } 101 | let docs = this.buildLine('/**', tabText); 102 | 103 | if (lines.length === 0) { 104 | // Ensure we always have at least one line. 105 | lines.push(''); 106 | } 107 | 108 | for (const line of lines) { 109 | docs += this.buildDocblockLine(line, tabText); 110 | } 111 | 112 | docs += this.buildLine(' */', tabText); 113 | 114 | return docs; 115 | } 116 | 117 | /** 118 | * @param {String} content 119 | * @param {String} tabText 120 | * 121 | * @return {String} 122 | */ 123 | buildDocblockLine(content, tabText) { 124 | if (tabText == null) { tabText = ''; } 125 | content = ` * ${content}`; 126 | 127 | return this.buildLine(content.trimRight(), tabText); 128 | } 129 | 130 | /** 131 | * @param {String} content 132 | * @param {String} tabText 133 | * 134 | * @return {String} 135 | */ 136 | buildLine(content, tabText) { 137 | if (tabText == null) { tabText = ''; } 138 | return `${tabText}${content}\n`; 139 | } 140 | }; 141 | -------------------------------------------------------------------------------- /lib/Refactoring/Utility/FunctionBuilder.js: -------------------------------------------------------------------------------- 1 | /* 2 | * decaffeinate suggestions: 3 | * DS207: Consider shorter variations of null checks 4 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 5 | */ 6 | module.exports = 7 | 8 | class FunctionBuilder { 9 | constructor() { 10 | /** 11 | * The access modifier (null if none). 12 | */ 13 | this.accessModifier = null; 14 | 15 | /** 16 | * Whether the method is static or not. 17 | */ 18 | this.isStatic = false; 19 | 20 | /** 21 | * Whether the method is abstract or not. 22 | */ 23 | this.isAbstract = null; 24 | 25 | /** 26 | * The name of the function. 27 | */ 28 | this.name = null; 29 | 30 | /** 31 | * The return type of the function. This could be set when generating PHP >= 7 methods. 32 | */ 33 | this.returnType = null; 34 | 35 | /** 36 | * The parameters of the function (a list of objects). 37 | */ 38 | this.parameters = []; 39 | 40 | /** 41 | * A list of statements to place in the body of the function. 42 | */ 43 | this.statements = []; 44 | 45 | /** 46 | * The tab text to insert on each line. 47 | */ 48 | this.tabText = ''; 49 | 50 | /** 51 | * The indentation level. 52 | */ 53 | this.indentationLevel = null; 54 | 55 | /** 56 | * The indentation level. 57 | * 58 | * @var {Number|null} 59 | */ 60 | this.maxLineLength = null; 61 | } 62 | 63 | /** 64 | * Makes the method public. 65 | * 66 | * @return {FunctionBuilder} 67 | */ 68 | makePublic() { 69 | this.accessModifier = 'public'; 70 | return this; 71 | } 72 | 73 | /** 74 | * Makes the method private. 75 | * 76 | * @return {FunctionBuilder} 77 | */ 78 | makePrivate() { 79 | this.accessModifier = 'private'; 80 | return this; 81 | } 82 | 83 | /** 84 | * Makes the method protected. 85 | * 86 | * @return {FunctionBuilder} 87 | */ 88 | makeProtected() { 89 | this.accessModifier = 'protected'; 90 | return this; 91 | } 92 | 93 | /** 94 | * Makes the method global (i.e. no access modifier is added). 95 | * 96 | * @return {FunctionBuilder} 97 | */ 98 | makeGlobal() { 99 | this.accessModifier = null; 100 | return this; 101 | } 102 | 103 | /** 104 | * Sets whether the method is static or not. 105 | * 106 | * @param {bool} isStatic 107 | * 108 | * @return {FunctionBuilder} 109 | */ 110 | setIsStatic(isStatic) { 111 | this.isStatic = isStatic; 112 | return this; 113 | } 114 | 115 | /** 116 | * Sets whether the method is abstract or not. 117 | * 118 | * @param {bool} isAbstract 119 | * 120 | * @return {FunctionBuilder} 121 | */ 122 | setIsAbstract(isAbstract) { 123 | this.isAbstract = isAbstract; 124 | return this; 125 | } 126 | 127 | /** 128 | * Sets the name of the function. 129 | * 130 | * @param {String} name 131 | * 132 | * @return {FunctionBuilder} 133 | */ 134 | setName(name) { 135 | this.name = name; 136 | return this; 137 | } 138 | 139 | /** 140 | * Sets the return type. 141 | * 142 | * @param {String|null} returnType 143 | * 144 | * @return {FunctionBuilder} 145 | */ 146 | setReturnType(returnType) { 147 | this.returnType = returnType; 148 | return this; 149 | } 150 | 151 | /** 152 | * Sets the parameters to add. 153 | * 154 | * @param {Array} parameters 155 | * 156 | * @return {FunctionBuilder} 157 | */ 158 | setParameters(parameters) { 159 | this.parameters = parameters; 160 | return this; 161 | } 162 | 163 | /** 164 | * Adds a parameter to the parameter list. 165 | * 166 | * @param {Object} parameter 167 | * 168 | * @return {FunctionBuilder} 169 | */ 170 | addParameter(parameter) { 171 | this.parameters.push(parameter); 172 | return this; 173 | } 174 | 175 | /** 176 | * Sets the statements to add. 177 | * 178 | * @param {Array} statements 179 | * 180 | * @return {FunctionBuilder} 181 | */ 182 | setStatements(statements) { 183 | this.statements = statements; 184 | return this; 185 | } 186 | 187 | /** 188 | * Adds a statement to the body of the function. 189 | * 190 | * @param {String} statement 191 | * 192 | * @return {FunctionBuilder} 193 | */ 194 | addStatement(statement) { 195 | this.statements.push(statement); 196 | return this; 197 | } 198 | 199 | /** 200 | * Sets the tab text to prepend to each line. 201 | * 202 | * @param {String} tabText 203 | * 204 | * @return {FunctionBuilder} 205 | */ 206 | setTabText(tabText) { 207 | this.tabText = tabText; 208 | return this; 209 | } 210 | 211 | /** 212 | * Sets the indentation level to use. The tab text is repeated this many times for each line. 213 | * 214 | * @param {Number} indentationLevel 215 | * 216 | * @return {FunctionBuilder} 217 | */ 218 | setIndentationLevel(indentationLevel) { 219 | this.indentationLevel = indentationLevel; 220 | return this; 221 | } 222 | 223 | /** 224 | * Sets the maximum length a single line may occupy. After this, text will wrap. 225 | * 226 | * This primarily influences parameter lists, which will automatically be split over multiple lines if the 227 | * parameter list would otherwise exceed the maximum length. 228 | * 229 | * @param {Number|null} maxLineLength The length or null to disable the maximum. 230 | * 231 | * @return {FunctionBuilder} 232 | */ 233 | setMaxLineLength(maxLineLength) { 234 | this.maxLineLength = maxLineLength; 235 | return this; 236 | } 237 | 238 | /** 239 | * Sets the parameters of the builder based on raw method data from the base service. 240 | * 241 | * @param {Object} data 242 | * 243 | * @return {FunctionBuilder} 244 | */ 245 | setFromRawMethodData(data) { 246 | if (data.isPublic) { 247 | this.makePublic(); 248 | 249 | } else if (data.isProtected) { 250 | this.makeProtected(); 251 | 252 | } else if (data.isPrivate) { 253 | this.makePrivate(); 254 | 255 | } else { 256 | this.makeGlobal(); 257 | } 258 | 259 | this.setName(data.name); 260 | this.setIsStatic(data.isStatic); 261 | this.setIsAbstract(data.isAbstract); 262 | this.setReturnType(data.returnTypeHint); 263 | 264 | const parameters = []; 265 | 266 | for (const parameter of data.parameters) { 267 | parameters.push({ 268 | name : `$${parameter.name}`, 269 | typeHint : parameter.typeHint, 270 | isVariadic : parameter.isVariadic, 271 | isReference : parameter.isReference, 272 | defaultValue : parameter.defaultValue 273 | }); 274 | } 275 | 276 | this.setParameters(parameters); 277 | 278 | return this; 279 | } 280 | 281 | /** 282 | * Builds the method using the preconfigured settings. 283 | * 284 | * @return {String} 285 | */ 286 | build() { 287 | let output = ''; 288 | 289 | const signature = this.buildSignature(false); 290 | 291 | if ((this.maxLineLength != null) && (signature.length > this.maxLineLength)) { 292 | output += this.buildSignature(true); 293 | output += ' {\n'; 294 | 295 | } else { 296 | output += signature + '\n'; 297 | output += this.buildLine('{'); 298 | } 299 | 300 | for (const statement of this.statements) { 301 | output += this.tabText + this.buildLine(statement); 302 | } 303 | 304 | output += this.buildLine('}'); 305 | 306 | return output; 307 | } 308 | 309 | /** 310 | * @param {Boolean} isMultiLine 311 | * 312 | * @return {String} 313 | */ 314 | buildSignature(isMultiLine) { 315 | let signatureLine = ''; 316 | 317 | if (this.isAbstract) { 318 | signatureLine += 'abstract '; 319 | } 320 | 321 | if (this.accessModifier != null) { 322 | signatureLine += `${this.accessModifier} `; 323 | } 324 | 325 | if (this.isStatic) { 326 | signatureLine += 'static '; 327 | } 328 | 329 | signatureLine += `function ${this.name}(`; 330 | 331 | const parameters = []; 332 | 333 | for (const parameter of this.parameters) { 334 | let parameterText = ''; 335 | 336 | if (parameter.typeHint != null) { 337 | parameterText += `${parameter.typeHint} `; 338 | } 339 | 340 | if (parameter.isVariadic) { 341 | parameterText += '...'; 342 | } 343 | 344 | if (parameter.isReference) { 345 | parameterText += '&'; 346 | } 347 | 348 | parameterText += `${parameter.name}`; 349 | 350 | if (parameter.defaultValue != null) { 351 | parameterText += ` = ${parameter.defaultValue}`; 352 | } 353 | 354 | parameters.push(parameterText); 355 | } 356 | 357 | if (!isMultiLine) { 358 | signatureLine += parameters.join(', '); 359 | signatureLine += ')'; 360 | 361 | signatureLine = this.addTabText(signatureLine); 362 | 363 | } else { 364 | signatureLine = this.buildLine(signatureLine); 365 | 366 | for (let i in parameters) { 367 | let parameter = parameters[i]; 368 | 369 | if (i < (parameters.length - 1)) { 370 | parameter += ','; 371 | } 372 | 373 | signatureLine += this.buildLine(parameter, this.indentationLevel + 1); 374 | } 375 | 376 | signatureLine += this.addTabText(')'); 377 | } 378 | 379 | if (this.returnType != null) { 380 | signatureLine += `: ${this.returnType}`; 381 | } 382 | 383 | return signatureLine; 384 | } 385 | 386 | /** 387 | * @param {String} content 388 | * @param {Number|null} indentationLevel 389 | * 390 | * @return {String} 391 | */ 392 | buildLine(content, indentationLevel = null) { 393 | return this.addTabText(content, indentationLevel) + '\n'; 394 | } 395 | 396 | /** 397 | * @param {String} content 398 | * @param {Number|null} indentationLevel 399 | * 400 | * @return {String} 401 | */ 402 | addTabText(content, indentationLevel = null) { 403 | if ((indentationLevel == null)) { 404 | ({ indentationLevel } = this); 405 | } 406 | 407 | const tabText = this.tabText.repeat(indentationLevel); 408 | 409 | return `${tabText}${content}`; 410 | } 411 | }; 412 | -------------------------------------------------------------------------------- /lib/Refactoring/Utility/MultiSelectionView.js: -------------------------------------------------------------------------------- 1 | /* global atom */ 2 | 3 | /* 4 | * decaffeinate suggestions: 5 | * DS102: Remove unnecessary code created because of implicit returns 6 | * DS207: Consider shorter variations of null checks 7 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 8 | */ 9 | const {$, $$, SelectListView} = require('atom-space-pen-views'); 10 | 11 | module.exports = 12 | 13 | /** 14 | * An extension on SelectListView from atom-space-pen-views that allows multiple selections. 15 | */ 16 | class MultiSelectionView extends SelectListView { 17 | /** 18 | * Constructor. 19 | * 20 | * @param {Callback} onDidConfirm 21 | * @param {Callback} onDidCancel 22 | */ 23 | constructor(onDidConfirm, onDidCancel = null) { 24 | super(); 25 | 26 | /** 27 | * The callback to invoke when the user confirms his selections. 28 | */ 29 | this.onDidConfirm = onDidConfirm; 30 | 31 | /** 32 | * The callback to invoke when the user cancels the view. 33 | */ 34 | this.onDidCancel = onDidCancel; 35 | 36 | /** 37 | * Metadata to pass to the callbacks. 38 | */ 39 | this.metadata = null; 40 | 41 | /** 42 | * The message to display when there are no results. 43 | */ 44 | this.emptyMessage = null; 45 | 46 | /** 47 | * Items that are currently selected. 48 | */ 49 | this.selectedItems = []; 50 | } 51 | 52 | /** 53 | * @inheritdoc 54 | */ 55 | initialize() { 56 | super.initialize(); 57 | 58 | this.addClass('php-ide-serenata-refactoring-multi-selection-view'); 59 | this.list.addClass('mark-active'); 60 | 61 | if (this.panel == null) { 62 | this.panel = atom.workspace.addModalPanel({ item: this, visible: false }); 63 | } 64 | 65 | return this.createWidgets(); 66 | } 67 | 68 | /** 69 | * Destroys the view and cleans up. 70 | */ 71 | destroy() { 72 | this.panel.destroy(); 73 | this.panel = null; 74 | } 75 | 76 | /** 77 | * Creates additional for the view. 78 | */ 79 | createWidgets() { 80 | const checkboxBar = $$(function() { 81 | return this.div({class: 'checkbox-bar settings-view'}, () => { 82 | return this.div({class: 'controls'}, () => { 83 | return this.div({class: 'block text-line'}, () => { 84 | return this.label( 85 | { class: 'icon icon-info' }, 86 | 'Tip: The order in which items are selected determines the order of the output.' 87 | ); 88 | }); 89 | }); 90 | }); 91 | }); 92 | 93 | checkboxBar.appendTo(this); 94 | 95 | // Ensure that button clicks are actually handled. 96 | this.on('mousedown', ({target}) => { 97 | if ($(target).hasClass('checkbox-input')) { return false; } 98 | if ($(target).hasClass('checkbox-label-text')) { return false; } 99 | }); 100 | 101 | const cancelButtonText = this.getCancelButtonText(); 102 | const confirmButtonText = this.getConfirmButtonText(); 103 | 104 | const buttonBar = $$(function() { 105 | return this.div({class: 'button-bar'}, () => { 106 | this.button({ 107 | class: 'btn btn-error inline-block-tight pull-left icon icon-circle-slash button--cancel' 108 | }, cancelButtonText); 109 | this.button({ 110 | class: 'btn btn-success inline-block-tight pull-right icon icon-gear button--confirm' 111 | }, confirmButtonText); 112 | return this.div({ class: 'clear-float' }); 113 | }); 114 | }); 115 | 116 | buttonBar.appendTo(this); 117 | 118 | this.on('click', 'button', event => { 119 | if ($(event.target).hasClass('button--confirm')) { this.confirmedByButton(); } 120 | if ($(event.target).hasClass('button--cancel')) { return this.cancel(); } 121 | }); 122 | 123 | this.on('keydown', event => { 124 | // Shift + Return 125 | if ((event.keyCode === 13) && (event.shiftKey === true)) { 126 | return this.confirmedByButton(); 127 | } 128 | }); 129 | 130 | // Ensure that button clicks are actually handled. 131 | return this.on('mousedown', ({target}) => { 132 | if ($(target).hasClass('btn')) { return false; } 133 | }); 134 | } 135 | 136 | /** 137 | * @inheritdoc 138 | */ 139 | viewForItem(item) { 140 | const classes = ['list-item']; 141 | 142 | if (item.className) { 143 | classes.push(item.className); 144 | } 145 | 146 | if (item.isSelected) { 147 | classes.push('active'); 148 | } 149 | 150 | const className = classes.join(' '); 151 | const displayText = item.name; 152 | 153 | return `\ 154 |
  • ${displayText}
  • \ 155 | `; 156 | } 157 | 158 | /** 159 | * @inheritdoc 160 | */ 161 | getFilterKey() { 162 | return 'name'; 163 | } 164 | 165 | /** 166 | * Retrieves the text to display on the cancel button. 167 | * 168 | * @return {string} 169 | */ 170 | getCancelButtonText() { 171 | return 'Cancel'; 172 | } 173 | 174 | /** 175 | * Retrieves the text to display on the confirmation button. 176 | * 177 | * @return {string} 178 | */ 179 | getConfirmButtonText() { 180 | return 'Generate'; 181 | } 182 | 183 | /** 184 | * Retrieves the message that is displayed when there are no results. 185 | * 186 | * @return {string} 187 | */ 188 | getEmptyMessage() { 189 | if (this.emptyMessage != null) { 190 | return this.emptyMessage; 191 | } 192 | 193 | return super.getEmptyMessage(); 194 | } 195 | 196 | /** 197 | * Sets the message that is displayed when there are no results. 198 | * 199 | * @param {string} emptyMessage 200 | */ 201 | setEmptyMessage(emptyMessage) { 202 | this.emptyMessage = emptyMessage; 203 | } 204 | 205 | /** 206 | * Retrieves the metadata to pass to the callbacks. 207 | * 208 | * @return {Object|null} 209 | */ 210 | getMetadata() { 211 | return this.metadata; 212 | } 213 | 214 | /** 215 | * Sets the metadata to pass to the callbacks. 216 | * 217 | * @param {Object|null} metadata 218 | */ 219 | setMetadata(metadata) { 220 | this.metadata = metadata; 221 | } 222 | 223 | /** 224 | * @inheritdoc 225 | */ 226 | setItems(items) { 227 | let i = 0; 228 | 229 | for (const item of items) { 230 | item.index = i++; 231 | } 232 | 233 | super.setItems(items); 234 | 235 | this.selectedItems = []; 236 | } 237 | 238 | /** 239 | * @inheritdoc 240 | */ 241 | confirmed(item) { 242 | let index; 243 | item.isSelected = !item.isSelected; 244 | 245 | if (item.isSelected) { 246 | this.selectedItems.push(item); 247 | 248 | } else { 249 | index = this.selectedItems.indexOf(item); 250 | 251 | if (index >= 0) { 252 | this.selectedItems.splice(index, 1); 253 | } 254 | } 255 | 256 | const selectedItem = this.getSelectedItem(); 257 | index = selectedItem ? selectedItem.index : 0; 258 | 259 | this.populateList(); 260 | 261 | return this.selectItemView(this.list.find(`li:nth(${index})`)); 262 | } 263 | 264 | /** 265 | * Invoked when the user confirms his selections by pressing the confirmation button. 266 | */ 267 | confirmedByButton() { 268 | this.invokeOnDidConfirm(); 269 | this.restoreFocus(); 270 | return this.panel.hide(); 271 | } 272 | 273 | /** 274 | * Invokes the on did confirm handler with the correct arguments (if it is set). 275 | */ 276 | invokeOnDidConfirm() { 277 | if (this.onDidConfirm) { 278 | return this.onDidConfirm(this.selectedItems, this.getMetadata()); 279 | } 280 | } 281 | 282 | /** 283 | * @inheritdoc 284 | */ 285 | cancelled() { 286 | if (this.onDidCancel) { 287 | this.onDidCancel(this.getMetadata()); 288 | } 289 | 290 | this.restoreFocus(); 291 | return this.panel.hide(); 292 | } 293 | 294 | /** 295 | * Presents the view to the user. 296 | */ 297 | present() { 298 | this.panel.show(); 299 | return this.focusFilterEditor(); 300 | } 301 | }; 302 | -------------------------------------------------------------------------------- /lib/Refactoring/Utility/TypeHelper.js: -------------------------------------------------------------------------------- 1 | /* 2 | * decaffeinate suggestions: 3 | * DS102: Remove unnecessary code created because of implicit returns 4 | * DS207: Consider shorter variations of null checks 5 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 6 | */ 7 | module.exports = 8 | 9 | class TypeHelper { 10 | constructor() { 11 | /** 12 | * @var {Object|null} service 13 | */ 14 | this.service = null; 15 | } 16 | 17 | /** 18 | * @param {Object} service 19 | */ 20 | setService(service) { 21 | this.service = service; 22 | } 23 | 24 | /** 25 | * @return {Number} 26 | */ 27 | getCurrentProjectPhpVersion() { 28 | const projectSettings = this.service.getCurrentProjectSettings(); 29 | 30 | if (projectSettings != null) { 31 | return projectSettings.phpVersion; 32 | } 33 | 34 | return 5.2; // Assume lowest supported version 35 | } 36 | 37 | /** 38 | * @param {String|null} typeSpecification 39 | * 40 | * @return {Object|null} 41 | */ 42 | getReturnTypeHintForTypeSpecification(typeSpecification) { 43 | if (this.getCurrentProjectPhpVersion() < 7.0) { return null; } 44 | 45 | const returnTypeHint = this.getTypeHintForTypeSpecification(typeSpecification); 46 | 47 | if ((returnTypeHint == null) || returnTypeHint.shouldSetDefaultValueToNull) { 48 | return null; 49 | } 50 | 51 | return returnTypeHint.typeHint; 52 | } 53 | 54 | /** 55 | * @param {String|null} typeSpecification 56 | * 57 | * @return {Object|null} 58 | */ 59 | getTypeHintForTypeSpecification(typeSpecification) { 60 | const types = this.getDocblockTypesFromDocblockTypeSpecification(typeSpecification); 61 | 62 | return this.getTypeHintForDocblockTypes(types); 63 | } 64 | 65 | /** 66 | * @param {String|null} typeSpecification 67 | * 68 | * @return {Array} 69 | */ 70 | getDocblockTypesFromDocblockTypeSpecification(typeSpecification) { 71 | if ((typeSpecification == null)) { return []; } 72 | return typeSpecification.split('|'); 73 | } 74 | 75 | /** 76 | * @param {Array} types 77 | * @param {boolean} allowPhp7 78 | * 79 | * @return {Object|null} 80 | */ 81 | getTypeHintForDocblockTypes(types) { 82 | let isNullable = false; 83 | 84 | types = types.filter(type => { 85 | if (type === 'null') { 86 | isNullable = true; 87 | } 88 | 89 | return type !== 'null'; 90 | }); 91 | 92 | let typeHint = null; 93 | let previousTypeHint = null; 94 | 95 | for (const type of types) { 96 | typeHint = this.getTypeHintForDocblockType(type); 97 | 98 | if ((previousTypeHint != null) && (typeHint !== previousTypeHint)) { 99 | // Several different type hints are necessary, we can't provide a common denominator. 100 | return null; 101 | } 102 | 103 | previousTypeHint = typeHint; 104 | } 105 | 106 | const data = { 107 | typeHint, 108 | shouldSetDefaultValueToNull : false 109 | }; 110 | 111 | if ((typeHint == null)) { return data; } 112 | if (!isNullable) { return data; } 113 | 114 | const currentPhpVersion = this.getCurrentProjectPhpVersion(); 115 | 116 | if (currentPhpVersion >= 7.1) { 117 | data.typeHint = `?${typeHint}`; 118 | data.shouldSetDefaultValueToNull = false; 119 | 120 | } else { 121 | data.shouldSetDefaultValueToNull = true; 122 | } 123 | 124 | return data; 125 | } 126 | 127 | /** 128 | * @param {String|null} type 129 | * 130 | * @return {String|null} 131 | */ 132 | getTypeHintForDocblockType(type) { 133 | if ((type == null)) { return null; } 134 | if (this.isClassType(type)) { return type; } 135 | return this.getScalarTypeHintForDocblockType(type); 136 | } 137 | 138 | /** 139 | * @param {String|null} type 140 | * 141 | * @return {boolean} 142 | */ 143 | isClassType(type) { 144 | if (this.getScalarTypeHintForDocblockType(type) === false) { return true; } else { return false; } 145 | } 146 | 147 | /** 148 | * @param {String|null} type 149 | * 150 | * @return {String|null|false} Null if the type is recognized, but there is no type hint available, false of the 151 | * type is not recognized at all, and the type hint itself if it is recognized and there is a type hint. 152 | */ 153 | getScalarTypeHintForDocblockType(type) { 154 | if ((type == null)) { return null; } 155 | 156 | const phpVersion = this.getCurrentProjectPhpVersion(); 157 | 158 | if (phpVersion >= 7.1) { 159 | if (type === 'iterable') { return 'iterable'; } 160 | if (type === 'void') { return 'void'; } 161 | 162 | } else if (phpVersion >= 7.0) { 163 | if (type === 'string') { return 'string'; } 164 | if (type === 'int') { return 'int'; } 165 | if (type === 'bool') { return 'bool'; } 166 | if (type === 'float') { return 'float'; } 167 | if (type === 'resource') { return 'resource'; } 168 | if (type === 'false') { return 'bool'; } 169 | if (type === 'true') { return 'bool'; } 170 | 171 | } else { 172 | if (type === 'string') { return null; } 173 | if (type === 'int') { return null; } 174 | if (type === 'bool') { return null; } 175 | if (type === 'float') { return null; } 176 | if (type === 'resource') { return null; } 177 | if (type === 'false') { return null; } 178 | if (type === 'true') { return null; } 179 | } 180 | 181 | if (type === 'array') { return 'array'; } 182 | if (type === 'callable') { return 'callable'; } 183 | if (type === 'self') { return 'self'; } 184 | if (type === 'static') { return 'self'; } 185 | if (/^.+\[\]$/.test(type)) { return 'array'; } 186 | 187 | if (type === 'object') { return null; } 188 | if (type === 'mixed') { return null; } 189 | if (type === 'void') { return null; } 190 | if (type === 'null') { return null; } 191 | if (type === 'parent') { return null; } 192 | if (type === '$this') { return null; } 193 | 194 | return false; 195 | } 196 | 197 | /** 198 | * Takes a type list (list of type objects) and turns them into a single docblock type specification. 199 | * 200 | * @param {Array} typeList 201 | * 202 | * @return {String} 203 | */ 204 | buildTypeSpecificationFromTypeArray(typeList) { 205 | const typeNames = typeList.map(type => type.type); 206 | 207 | return this.buildTypeSpecificationFromTypes(typeNames); 208 | } 209 | 210 | /** 211 | * Takes a list of type names and turns them into a single docblock type specification. 212 | * 213 | * @param {Array} typeNames 214 | * 215 | * @return {String} 216 | */ 217 | buildTypeSpecificationFromTypes(typeNames) { 218 | if (typeNames.length === 0) { return 'mixed'; } 219 | 220 | return typeNames.join('|'); 221 | } 222 | }; 223 | -------------------------------------------------------------------------------- /lib/ServerManager.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = 4 | 5 | /** 6 | * Handles management of the (PHP) server that is needed to handle the server side. 7 | */ 8 | class ServerManager 9 | { 10 | /** 11 | * @param {Object} phpInvoker 12 | * @param {String} folder 13 | */ 14 | constructor(phpInvoker, folder) { 15 | this.phpInvoker = phpInvoker; 16 | this.folder = folder; 17 | 18 | this.distributionJobNumber = '735379565'; 19 | } 20 | 21 | /** 22 | * @return Promise 23 | */ 24 | async install() { 25 | const download = require('download'); 26 | 27 | // TODO: Serenata offers PHARs for each PHP version it supports, but for now we can get away with using the 28 | // lowest PHP version, as newer versions are backwards compatible enough. 29 | await download( 30 | `https://gitlab.com/Serenata/Serenata/-/jobs/${this.distributionJobNumber}` + 31 | `/artifacts/raw/bin/distribution.phar`, 32 | this.phpInvoker.normalizePlatformAndRuntimePath(this.getServerSourcePath()), 33 | { 34 | filename: 'distribution.phar', 35 | } 36 | ); 37 | 38 | return new Promise((resolve, reject) => { 39 | const fs = require('fs'); 40 | 41 | fs.writeFile(this.getVersionSpecificationFilePath(), this.versionSpecification, (error) => { 42 | if (error) { 43 | reject(new Error(error.message)); 44 | } else { 45 | resolve(); 46 | } 47 | }); 48 | }); 49 | } 50 | 51 | /** 52 | * @return {Boolean} 53 | */ 54 | isInstalled() { 55 | const fs = require('fs'); 56 | 57 | return fs.existsSync(this.getVersionSpecificationFilePath()); 58 | } 59 | 60 | /** 61 | * @return {String} 62 | */ 63 | getServerSourcePath() { 64 | if (this.folder === null || this.folder.length === 0) { 65 | throw new Error('Failed producing a usable server source folder path'); 66 | } else if (this.folder === '/') { 67 | // Can never be too careful with dynamic path generation (and recursive deletes). 68 | throw new Error('Nope, I\'m not going to use your filesystem root'); 69 | } 70 | 71 | return this.folder; 72 | } 73 | 74 | /** 75 | * @return {String} 76 | */ 77 | getServerExecutablePath() { 78 | const path = require('path'); 79 | 80 | return path.join(this.getServerSourcePath(), 'distribution.phar'); 81 | } 82 | 83 | /** 84 | * @return {String} 85 | */ 86 | getVersionSpecificationFilePath() { 87 | const path = require('path'); 88 | 89 | return path.join(this.folder, this.distributionJobNumber); 90 | } 91 | }; 92 | -------------------------------------------------------------------------------- /lib/ServiceContainer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = 4 | 5 | /** 6 | * Container that provides instances of application services. 7 | */ 8 | class ServiceContainer 9 | { 10 | constructor() { 11 | this.packageName = 'php-ide-serenata'; 12 | 13 | this.configuration = null; 14 | this.phpInvoker = null; 15 | this.proxy = null; 16 | this.composerService = null; 17 | this.serverManager = null; 18 | this.useStatementHelper = null; 19 | this.projectManager = null; 20 | this.codeLensManager = null; 21 | this.serenataClient = null; 22 | } 23 | 24 | getConfiguration() { 25 | if ((this.configuration === null)) { 26 | const AtomConfig = require('./AtomConfig'); 27 | this.configuration = new AtomConfig(this.packageName); 28 | this.configuration.load(); 29 | } 30 | 31 | return this.configuration; 32 | } 33 | 34 | getPhpInvoker() { 35 | if ((this.phpInvoker === null)) { 36 | const PhpInvoker = require('./PhpInvoker'); 37 | this.phpInvoker = new PhpInvoker(this.getConfiguration()); 38 | } 39 | 40 | return this.phpInvoker; 41 | } 42 | 43 | getProxy() { 44 | if ((this.proxy === null)) { 45 | const Proxy = require('./Proxy'); 46 | this.proxy = new Proxy(this.getConfiguration(), this.getPhpInvoker()); 47 | this.proxy.setServerPath(this.getServerManager().getServerSourcePath()); 48 | } 49 | 50 | return this.proxy; 51 | } 52 | 53 | getServerManager() { 54 | if ((this.serverManager === null)) { 55 | const ServerManager = require('./ServerManager'); 56 | this.serverManager = new ServerManager( 57 | this.getPhpInvoker(), 58 | this.getConfiguration().get('storagePath') + '/' 59 | ); 60 | } 61 | 62 | return this.serverManager; 63 | } 64 | 65 | getUseStatementHelper() { 66 | if ((this.useStatementHelper === null)) { 67 | const UseStatementHelper = require('./UseStatementHelper'); 68 | this.useStatementHelper = new UseStatementHelper(true); 69 | } 70 | 71 | return this.useStatementHelper; 72 | } 73 | 74 | getProjectManager() { 75 | if (this.projectManager === null) { 76 | const ProjectManager = require('./ProjectManager'); 77 | this.projectManager = new ProjectManager(this.getProxy(), this.getConfiguration()); 78 | } 79 | 80 | return this.projectManager; 81 | } 82 | 83 | getCodeLensManager() { 84 | if (this.codeLensManager === null) { 85 | const CodeLensManager = require('./CodeLensManager'); 86 | this.codeLensManager = new CodeLensManager(); 87 | } 88 | 89 | return this.codeLensManager; 90 | } 91 | 92 | getSerenataClient() { 93 | if (this.serenataClient === null) { 94 | const SerenataClient = require('./SerenataClient'); 95 | this.serenataClient = new SerenataClient( 96 | this, 97 | this.packageName 98 | ); 99 | } 100 | 101 | return this.serenataClient; 102 | } 103 | }; 104 | -------------------------------------------------------------------------------- /lib/UseStatementHelper.js: -------------------------------------------------------------------------------- 1 | /* 2 | * decaffeinate suggestions: 3 | * DS102: Remove unnecessary code created because of implicit returns 4 | * DS104: Avoid inline assignments 5 | * DS202: Simplify dynamic range loops 6 | * DS205: Consider reworking code to avoid use of IIFEs 7 | * DS207: Consider shorter variations of null checks 8 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 9 | */ 10 | module.exports = 11 | 12 | //#* 13 | // Contains convenience methods for dealing with use statements. 14 | //# 15 | class UseStatementHelper { 16 | /** 17 | * @param {Boolean} allowAdditionalNewlines 18 | */ 19 | constructor(allowAdditionalNewlines) { 20 | /** 21 | * Regular expression that will search for a structure (class, interface, trait, ...). 22 | * 23 | * @var {RegExp} 24 | */ 25 | this.structureStartRegex = /(?:abstract class|class|trait|interface)\s+(\w+)/; 26 | 27 | /** 28 | * Regular expression that will search for a use statement. 29 | * 30 | * @var {RegExp} 31 | */ 32 | this.useStatementRegex = /(?:use)(?:[^\w\\])([\w\\]+)(?![\w\\])(?:(?:[ ]+as[ ]+)(\w+))?(?:;)/; 33 | 34 | /** 35 | * Whether to allow adding additional newlines to attempt to group use statements. 36 | * 37 | * @var {Boolean} 38 | */ 39 | this.allowAdditionalNewlines = allowAdditionalNewlines; 40 | } 41 | 42 | /** 43 | * @param {Boolean} allowAdditionalNewlines 44 | */ 45 | setAllowAdditionalNewlines(allowAdditionalNewlines) { 46 | this.allowAdditionalNewlines = allowAdditionalNewlines; 47 | } 48 | 49 | /** 50 | * Add the use for the given class if not already added. 51 | * 52 | * @param {TextEditor} editor Pulsar text editor. 53 | * @param {String} className Name of the class to add. 54 | * 55 | * @return {Number} The amount of lines added (including newlines), so you can reliably and easily offset your 56 | * rows. This could be zero if a use statement was already present. 57 | */ 58 | addUseClass(editor, className) { 59 | let i, line, matches, scopeDescriptor; 60 | let asc, end; 61 | let asc1, end1; 62 | let bestUseRow = 0; 63 | let placeBelow = true; 64 | let doNewLine = true; 65 | const lineCount = editor.getLineCount(); 66 | let previousMatchThatSharedNamespacePrefixRow = null; 67 | 68 | // First see if the use statement is already present. The next loop stops early (and can't do this). 69 | for (i = 0, end = lineCount - 1, asc = 0 <= end; asc ? i <= end : i >= end; asc ? i++ : i--) { 70 | line = editor.lineTextForBufferRow(i).trim(); 71 | 72 | if (line.length === 0) { continue; } 73 | 74 | scopeDescriptor = editor.scopeDescriptorForBufferPosition([i, line.length]).getScopeChain(); 75 | 76 | if (scopeDescriptor.indexOf('.comment') >= 0) { 77 | continue; 78 | } 79 | 80 | if (line.match(this.structureStartRegex)) { break; } 81 | 82 | if (matches = this.useStatementRegex.exec(line)) { 83 | if ((matches[1] === className) || ((matches[1][0] === '\\') && 84 | (matches[1].substr(1) === className))) { 85 | return 0; 86 | } 87 | } 88 | } 89 | 90 | // Determine an appropriate location to place the use statement. 91 | for (i = 0, end1 = lineCount - 1, asc1 = 0 <= end1; asc1 ? i <= end1 : i >= end1; asc1 ? i++ : i--) { 92 | line = editor.lineTextForBufferRow(i).trim(); 93 | 94 | if (line.length === 0) { continue; } 95 | 96 | scopeDescriptor = editor.scopeDescriptorForBufferPosition([i, line.length]).getScopeChain(); 97 | 98 | if (scopeDescriptor.indexOf('.comment') >= 0) { 99 | continue; 100 | } 101 | 102 | if (line.match(this.structureStartRegex)) { break; } 103 | 104 | if (line.indexOf('namespace ') >= 0) { 105 | bestUseRow = i; 106 | } 107 | 108 | if (matches = this.useStatementRegex.exec(line)) { 109 | bestUseRow = i; 110 | 111 | placeBelow = true; 112 | const shareCommonNamespacePrefix = this.doShareCommonNamespacePrefix(className, matches[1]); 113 | 114 | doNewLine = !shareCommonNamespacePrefix; 115 | 116 | if (this.scoreClassName(className, matches[1]) <= 0) { 117 | placeBelow = false; 118 | 119 | // Normally we keep going until the sorting indicates we should stop, and then place the use 120 | // statement above the 'incorrect' match, but if the previous use statement was a use statement 121 | // that has the same namespace, we want to ensure we stick close to it instead of creating 122 | // additional newlines (which the item from the same namespace already placed). 123 | if (previousMatchThatSharedNamespacePrefixRow != null) { 124 | placeBelow = true; 125 | doNewLine = false; 126 | bestUseRow = previousMatchThatSharedNamespacePrefixRow; 127 | } 128 | 129 | break; 130 | } 131 | 132 | previousMatchThatSharedNamespacePrefixRow = shareCommonNamespacePrefix ? i : null; 133 | } 134 | } 135 | 136 | // Insert the use statement itself. 137 | let lineEnding = editor.getBuffer().lineEndingForRow(0); 138 | 139 | if (!this.allowAdditionalNewlines) { 140 | doNewLine = false; 141 | } 142 | 143 | if (!lineEnding) { 144 | lineEnding = '\n'; 145 | } 146 | 147 | let textToInsert = ''; 148 | 149 | if (doNewLine && placeBelow) { 150 | textToInsert += lineEnding; 151 | } 152 | 153 | textToInsert += `use ${className};` + lineEnding; 154 | 155 | if (doNewLine && !placeBelow) { 156 | textToInsert += lineEnding; 157 | } 158 | 159 | const lineToInsertAt = bestUseRow + (placeBelow ? 1 : 0); 160 | editor.setTextInBufferRange([[lineToInsertAt, 0], [lineToInsertAt, 0]], textToInsert); 161 | 162 | return (1 + (doNewLine ? 1 : 0)); 163 | } 164 | 165 | /** 166 | * Returns a boolean indicating if the specified class names share a common namespace prefix. 167 | * 168 | * @param {String} firstClassName 169 | * @param {String} secondClassName 170 | * 171 | * @return {Boolean} 172 | */ 173 | doShareCommonNamespacePrefix(firstClassName, secondClassName) { 174 | const firstClassNameParts = firstClassName.split('\\'); 175 | const secondClassNameParts = secondClassName.split('\\'); 176 | 177 | firstClassNameParts.pop(); 178 | secondClassNameParts.pop(); 179 | 180 | if (firstClassNameParts.join('\\') === secondClassNameParts.join('\\')) { 181 | return true; 182 | } 183 | 184 | return false; 185 | } 186 | 187 | /** 188 | * Scores the first class name against the second, indicating how much they 'match' each other. This can be used 189 | * to e.g. find an appropriate location to place a class in an existing list of classes. 190 | * 191 | * @param {String} firstClassName 192 | * @param {String} secondClassName 193 | * 194 | * @return {Number} A floating point number that represents the score. 195 | */ 196 | scoreClassName(firstClassName, secondClassName) { 197 | const firstClassNameParts = firstClassName.split('\\'); 198 | const secondClassNameParts = secondClassName.split('\\'); 199 | 200 | const collator = new Intl.Collator; 201 | const maxLength = Math.min(firstClassNameParts.length, secondClassNameParts.length); 202 | 203 | // Always sort unqualified imports before everything else. 204 | if (firstClassNameParts.length !== secondClassNameParts.length) { 205 | if (firstClassNameParts.length <= 1) { 206 | return -1; 207 | } else if (secondClassNameParts.length <= 1) { 208 | return 1; 209 | } 210 | } 211 | 212 | for (let i = 0, end = maxLength - 1, asc = 0 <= end; asc ? i <= end : i >= end; asc ? i++ : i--) { 213 | if (firstClassNameParts[i] !== secondClassNameParts[i]) { 214 | // For use statements that only differ in the last segment (with a common namespace segment), 215 | // sort the last part by length so we get a neat gradually expanding half of a christmas tree. 216 | if (firstClassNameParts[i].length !== secondClassNameParts[i].length && 217 | firstClassNameParts.length === secondClassNameParts.length && 218 | i === (maxLength - 1) 219 | ) { 220 | return firstClassNameParts[i].length > secondClassNameParts[i].length ? 1 : -1; 221 | } 222 | 223 | return collator.compare(firstClassNameParts[i], secondClassNameParts[i]); 224 | } 225 | } 226 | 227 | return firstClassNameParts.length > secondClassNameParts.length ? 1 : -1; 228 | } 229 | 230 | /** 231 | * Sorts the use statements in the specified file according to the same algorithm used by 'addUseClass'. 232 | * 233 | * @param {TextEditor} editor 234 | */ 235 | sortUseStatements(editor) { 236 | let endLine = null; 237 | let startLine = null; 238 | const useStatements = []; 239 | 240 | for (let i = 0, end = editor.getLineCount(), asc = 0 <= end; asc ? i <= end : i >= end; asc ? i++ : i--) { 241 | var matches; 242 | const lineText = editor.lineTextForBufferRow(i); 243 | 244 | endLine = i; 245 | 246 | if (!lineText || (lineText.trim() === '')) { 247 | continue; 248 | 249 | } else if (matches = this.useStatementRegex.exec(lineText)) { 250 | if (!startLine) { 251 | startLine = i; 252 | } 253 | 254 | let text = matches[1]; 255 | 256 | if (matches[2] != null) { 257 | text += ` as ${matches[2]}`; 258 | } 259 | 260 | useStatements.push(text); 261 | 262 | // We still do the regex check here to prevent continuing when there are no use statements at all. 263 | } else if (startLine || this.structureStartRegex.test(lineText)) { 264 | break; 265 | } 266 | } 267 | 268 | if (useStatements.length === 0) { return; } 269 | 270 | return editor.transact(() => { 271 | editor.setTextInBufferRange([[startLine, 0], [endLine, 0]], ''); 272 | 273 | return (() => { 274 | const result = []; 275 | for (let useStatement of useStatements) { 276 | // The leading slash is unnecessary, not recommended, and messes up sorting, take it out. 277 | if (useStatement[0] === '\\') { 278 | useStatement = useStatement.substr(1); 279 | } 280 | 281 | result.push(this.addUseClass(editor, useStatement, this.allowAdditionalNewlines)); 282 | } 283 | return result; 284 | })(); 285 | }); 286 | } 287 | }; 288 | -------------------------------------------------------------------------------- /menus/menu.cson: -------------------------------------------------------------------------------- 1 | 'menu': [ 2 | { 3 | 'label': 'Packages' 4 | 'submenu': [ 5 | { 6 | 'label': 'Serenata', 7 | 'submenu': [ 8 | {'label': 'Set Up Current Project', 'command': 'php-ide-serenata:set-up-current-project'}, 9 | {'label': '(Re)index Project', 'command': 'php-ide-serenata:index-project'}, 10 | {'label': 'Forcibly (Re)index Project', 'command': 'php-ide-serenata:force-index-project'}, 11 | {'type' : 'separator'}, 12 | {'label': 'Sort Use Statements', 'command': 'php-ide-serenata:sort-use-statements'}, 13 | ] 14 | } 15 | ] 16 | } 17 | ] 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "php-ide-serenata", 3 | "main": "./lib/Main", 4 | "version": "5.5.1", 5 | "description": "PHP language support for Pulsar-IDE via the Serenata server", 6 | "repository": "git@github.com:Gert-dev/php-ide-serenata", 7 | "homepage": "https://serenata.gitlab.io/", 8 | "license": "GPL-3.0-or-later", 9 | "engines": { 10 | "atom": ">=1.26.0 <2.0.0" 11 | }, 12 | "providedServices": { 13 | "intentions:list": { 14 | "versions": { 15 | "1.0.0": "provideIntentions" 16 | } 17 | }, 18 | "definitions": { 19 | "versions": { 20 | "0.1.0": "provideDefinitions" 21 | } 22 | }, 23 | "autocomplete.provider": { 24 | "versions": { 25 | "4.0.0": "provideAutocomplete" 26 | } 27 | }, 28 | "outline-view": { 29 | "versions": { 30 | "0.1.0": "provideOutlines" 31 | } 32 | }, 33 | "code-highlight": { 34 | "versions": { 35 | "0.1.0": "provideCodeHighlight" 36 | } 37 | } 38 | }, 39 | "consumedServices": { 40 | "snippets": { 41 | "versions": { 42 | "0.1.0": "setSnippetManager" 43 | } 44 | }, 45 | "linter-indie": { 46 | "versions": { 47 | "2.0.0": "consumeLinterV2" 48 | } 49 | }, 50 | "atom-ide-busy-signal": { 51 | "versions": { 52 | "0.1.0": "consumeBusySignal" 53 | } 54 | }, 55 | "datatip": { 56 | "versions": { 57 | "0.1.0": "consumeDatatip" 58 | } 59 | }, 60 | "signature-help": { 61 | "versions": { 62 | "0.1.0": "consumeSignatureHelp" 63 | } 64 | }, 65 | "console": { 66 | "versions": { 67 | "0.1.0": "consumeConsole" 68 | } 69 | } 70 | }, 71 | "dependencies": { 72 | "atom-languageclient": "^1.16.0", 73 | "atom-package-deps": "^5.0", 74 | "atom-space-pen-views": "^2.2", 75 | "download": "^7.1", 76 | "mkdirp": "^0.5.5" 77 | }, 78 | "package-deps": [ 79 | "atom-ide-ui", 80 | "intentions" 81 | ], 82 | "keywords": [ 83 | "serenata", 84 | "php", 85 | "ide", 86 | "integration", 87 | "autocompletion", 88 | "refactoring", 89 | "docblock", 90 | "generator" 91 | ], 92 | "devDependencies": { 93 | "eslint": "^6.8.0" 94 | }, 95 | "configSchema": { 96 | "core": { 97 | "type": "object", 98 | "order": 1, 99 | "properties": { 100 | "phpExecutionType": { 101 | "title": "PHP execution type", 102 | "description": "How to start PHP, which is needed to start the server 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 When using containers, project paths open in Pulsar are automatically mounted into the container at the same path. If you want to specify more exotic paths for Serenata to index in your project file, you have to ensure these are mounted in the container as well. \n \n Requires a restart after changing. \n \n", 103 | "type": "string", 104 | "default": "host", 105 | "order": 1, 106 | "enum": [ 107 | { 108 | "value": "host", 109 | "description": "Use PHP on the host" 110 | }, 111 | { 112 | "value": "docker", 113 | "description": "Use a PHP container via Docker (experimental)" 114 | }, 115 | { 116 | "value": "docker-polkit", 117 | "description": "Use a PHP container via Docker, using PolicyKit for privilege escalation (experimental, Linux only)" 118 | }, 119 | { 120 | "value": "podman", 121 | "description": "Use a PHP container via Podman, avoiding privilege escalation entirely (experimental, Linux only)" 122 | } 123 | ] 124 | }, 125 | "phpCommand": { 126 | "title": "PHP command", 127 | "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.", 128 | "type": "string", 129 | "default": "php", 130 | "order": 2 131 | }, 132 | "memoryLimit": { 133 | "title": "Memory limit (in MB)", 134 | "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.", 135 | "type": "integer", 136 | "default": 2048, 137 | "order": 3 138 | }, 139 | "additionalDockerVolumes": { 140 | "title": "Additional Docker volumes", 141 | "description": "Additional paths to mount as Docker volumes. Only applies when using Docker to run the server. 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.", 142 | "type": "array", 143 | "default": [], 144 | "order": 4, 145 | "items": { 146 | "type": "string" 147 | } 148 | } 149 | } 150 | }, 151 | "refactoring": { 152 | "type": "object", 153 | "order": 3, 154 | "properties": { 155 | "enable": { 156 | "title": "Enable", 157 | "description": "When enabled, refactoring actions will be available via the intentions package.", 158 | "type": "boolean", 159 | "default": true, 160 | "order": 1 161 | } 162 | } 163 | } 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /styles/main.less: -------------------------------------------------------------------------------- 1 | // The ui-variables file is provided by base themes provided by Pulsar. 2 | // 3 | // See https://github.com/atom/atom-dark-ui/blob/master/stylesheets/ui-variables.less 4 | // for a full listing of what's available. 5 | @import "ui-variables"; 6 | @import "octicon-utf-codes"; 7 | 8 | .php-ide-serenata-autocompletion-strike, .php-ide-serenata-autocompletion-strike .word { 9 | text-decoration: line-through; 10 | } 11 | 12 | .php-ide-serenata-autocompletion-suggestion { 13 | &.php-ide-serenata-autocompletion-has-additional-icons { 14 | .icon-container { 15 | padding-right: 0; 16 | } 17 | } 18 | 19 | .left-label { 20 | .icon { 21 | float: left; 22 | padding-top: 0.25em; 23 | padding-left: 0.5em; 24 | padding-right: 2em; 25 | margin-right: 0.75em; 26 | } 27 | } 28 | 29 | .right-label { 30 | float: right; 31 | } 32 | } 33 | 34 | .php-ide-serenata-refactoring-strikethrough { 35 | text-decoration: line-through; 36 | } 37 | 38 | .php-ide-serenata-refactoring-multi-selection-view { 39 | .list-item { 40 | 41 | } 42 | 43 | .checkbox { 44 | font-size: x-small; 45 | } 46 | 47 | .text-line { 48 | // margin-top: 0.5em; 49 | } 50 | 51 | .button-bar { 52 | // margin-top: 0.5em; 53 | 54 | .clear-float { 55 | clear: both; 56 | } 57 | } 58 | } 59 | 60 | .php-ide-serenata-refactoring-extract-method { 61 | .section-body, .form-control, .control-label, .control-group, .controls { 62 | width: 100%; 63 | } 64 | .preview-area { 65 | atom-text-editor .editor-contents--private { 66 | max-height: 400px !important; 67 | } 68 | } 69 | // Should change to reflect the padding of the theme instead of hardcoded. 70 | .block { 71 | margin-top: 4px; 72 | } 73 | .pull-right { 74 | .inline-block { 75 | margin-left: 9px; 76 | } 77 | } 78 | 79 | .checkbox { 80 | // Removing any extra margining to the checkboxes (e.g. Atom Material) 81 | margin-top: 5px; 82 | margin-bottom: 5px; 83 | } 84 | 85 | .settings-view .section-body { 86 | margin-top: 0px; 87 | } 88 | 89 | .button-bar { 90 | .clear-float { 91 | clear: both; 92 | } 93 | } 94 | } 95 | 96 | .php-ide-serenata-code-lens { 97 | // a { 98 | // &:extend(.badge); 99 | // &:extend(.badge-small); 100 | // } 101 | 102 | display: inline-block; 103 | } 104 | 105 | .php-ide-serenata-code-lens-wrapper { 106 | } 107 | --------------------------------------------------------------------------------