├── test ├── helpers │ └── points.helper.js ├── runner.js ├── main.test.js ├── removeUnusedImportActionProvider.test.js ├── actionProviderComposer.test.js └── autoImportActionProvider.test.js ├── lib ├── providers │ ├── index.js │ ├── removeUnusedImportActionProvider.js │ ├── actionProviderComposer.js │ └── autoImportActionProvider.js ├── views │ └── importSuggestionListView.js └── main.js ├── .github ├── issue_template.md └── workflows │ └── ci.yml ├── LICENSE.md ├── README.md ├── CHANGELOG.md ├── .gitignore └── package.json /test/helpers/points.helper.js: -------------------------------------------------------------------------------- 1 | function pointsAreEqual(p1, p2) { 2 | return p1.row === p2.row && p1.column === p2.column 3 | } 4 | 5 | module.exports = {pointsAreEqual} 6 | -------------------------------------------------------------------------------- /lib/providers/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | actionProviderComposer: require('./actionProviderComposer'), 3 | actionProviders: { 4 | autoImportProvider: require('./autoImportActionProvider').provider, 5 | removeUnusedImportProvider: require('./removeUnusedImportActionProvider').provider 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | A few things to know before you create an issue 2 | 3 | * We launch a "language server" process on your machine that understands the language you are editing 4 | * What that server can do - syntax compatibility, whether it supports formatting or outlines etc - is outside of our control 5 | * You can see what the language server supports by checking out our README and following the link after "powered by" 6 | 7 | If you understand these constraints, please remove this text and start writing your issue :) 8 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | env: 6 | CI: true 7 | 8 | jobs: 9 | Test: 10 | strategy: 11 | matrix: 12 | os: [ubuntu-latest, macos-latest, windows-latest] 13 | channel: [stable, beta] 14 | runs-on: ${{ matrix.os }} 15 | steps: 16 | - uses: actions/checkout@v1 17 | - uses: UziTech/action-setup-atom@v2 18 | with: 19 | version: ${{ matrix.channel }} 20 | - name: Install dependencies 21 | run: apm install 22 | - name: Run tests 23 | run: atom --test test 24 | -------------------------------------------------------------------------------- /lib/providers/removeUnusedImportActionProvider.js: -------------------------------------------------------------------------------- 1 | function removeUnusedImportActionProvider(editor, range, diagnostic) { 2 | if (!isUnusedImport(diagnostic)) return 3 | 4 | return { 5 | getTitle() { return Promise.resolve('Remove unused import') }, 6 | dispose() {}, 7 | apply() { 8 | const selections = editor.getSelectedBufferRanges() 9 | 10 | editor.setSelectedBufferRange(range) 11 | editor.deleteLine() 12 | 13 | editor.setSelectedBufferRanges( 14 | selections.map(s => ({ 15 | start: Object.assign({}, s.start, { row: s.start.row - 1 }), 16 | end: Object.assign({}, s.end, { row: s.end.row - 1 }) 17 | })) 18 | ); 19 | 20 | return Promise.resolve() 21 | } 22 | } 23 | } 24 | 25 | function isUnusedImport({ text }) { 26 | const unusedImportExpr = /The import [a-z0-9_\.]+ is never used/ig 27 | return unusedImportExpr.test(text) 28 | } 29 | 30 | module.exports = { 31 | provider: removeUnusedImportActionProvider, 32 | isUnusedImport 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 GitHub Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /test/runner.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const chai = require('chai') 3 | const {createRunner} = require('atom-mocha-test-runner') 4 | 5 | global.assert = chai.assert 6 | global.expect = chai.expect 7 | 8 | global.stress = function(count, ...args) { 9 | const [description, ...rest] = args 10 | for (let i = 0; i < count; i++) { 11 | it.only(`${description} #${i}`, ...rest) 12 | } 13 | } 14 | 15 | module.exports = createRunner({ 16 | htmlTitle: `IDE-Java Package Tests - pid ${process.pid}`, 17 | reporter: process.env.MOCHA_REPORTER || 'spec', 18 | overrideTestPaths: [/spec$/, /test/], 19 | }, mocha => { 20 | mocha.timeout(parseInt(process.env.MOCHA_TIMEOUT || '5000', 10)) 21 | 22 | if (process.env.TEST_JUNIT_XML_PATH) { 23 | mocha.reporter(require('mocha-junit-and-console-reporter'), { 24 | mochaFile: process.env.TEST_JUNIT_XML_PATH, 25 | }) 26 | } else if (process.env.APPVEYOR_API_URL) { 27 | mocha.reporter(require('mocha-appveyor-reporter')) 28 | } else if (process.env.CIRCLECI === 'true') { 29 | mocha.reporter(require('mocha-junit-and-console-reporter'), { 30 | mochaFile: path.join(process.env.CIRCLE_TEST_REPORTS, 'mocha', 'test-results.xml'), 31 | }) 32 | } 33 | }) 34 | -------------------------------------------------------------------------------- /lib/views/importSuggestionListView.js: -------------------------------------------------------------------------------- 1 | const { TextEditor } = require('atom') 2 | const SelectList = require('atom-select-list') 3 | 4 | class ImportSuggestionListView { 5 | constructor(importSuggestions = []) { 6 | if (importSuggestions.length === 0) 7 | throw new Error('Import suggestions list cannot be empty') 8 | 9 | let listInitializer = { 10 | items: importSuggestions, 11 | elementForItem: (itm, { index }) => { 12 | const $listItm = document.createElement('li') 13 | $listItm.innerHTML = itm 14 | return $listItm 15 | } 16 | } 17 | 18 | this._promise = new Promise(resolve => { 19 | const resWith = (itm = '') => { 20 | if (itm) resolve(itm) 21 | this.close() 22 | } 23 | 24 | listInitializer.didConfirmSelection = itm => resWith(itm) 25 | listInitializer.didCancelSelection = () => resWith() 26 | listInitializer.didConfirmEmptySelection = () => resWith() 27 | }) 28 | 29 | this.selectList = new SelectList(listInitializer) 30 | this.panel = atom.workspace.addModalPanel({ item: this.selectList.element }) 31 | this.panel.show() 32 | this.selectList.focus() 33 | } 34 | 35 | waitForConfirmOrCancel() { return this._promise } 36 | 37 | close() { 38 | if (this.panel) { 39 | this.panel.destroy() 40 | 41 | const activeItem = atom.workspace.getActivePaneItem() 42 | if (activeItem && activeItem instanceof TextEditor) 43 | activeItem.element.focus(); 44 | } 45 | } 46 | } 47 | 48 | module.exports = ImportSuggestionListView 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ##### Atom and all repositories under Atom will be archived on December 15, 2022. Learn more in our [official announcement](https://github.blog/2022-06-08-sunsetting-atom/) 2 | # IDE-Java package 3 | [![CI](https://github.com/atom/ide-java/actions/workflows/ci.yml/badge.svg)](https://github.com/atom/ide-java/actions/workflows/ci.yml) 4 | 5 | Java language support for Atom-IDE, powered by the [Eclipse JDT language server](https://github.com/eclipse/eclipse.jdt.ls). 6 | 7 | ![Screenshot of IDE-Java](https://user-images.githubusercontent.com/118951/30291233-0b6e04ac-96e7-11e7-9aa8-3cc6143537c1.png) 8 | 9 | ## Early access 10 | This package is currently an early access release. You should also install the [atom-ide-ui](https://atom.io/packages/atom-ide-ui) package to expose the functionality within Atom. 11 | 12 | ## Features 13 | 14 | * Auto completion 15 | * Code format 16 | * Diagnostics (errors & warnings) 17 | * Document outline 18 | * Find references 19 | * Go to definition 20 | * Hover 21 | * Reference highlighting 22 | * Signature help 23 | 24 | ## Contributing 25 | Always feel free to help out! Whether it's [filing bugs and feature requests](https://github.com/atom/languageserver-java/issues/new) or working on some of the [open issues](https://github.com/atom/languageserver-java/issues), Atom's [contributing guide](https://github.com/atom/atom/blob/master/CONTRIBUTING.md) will help get you started while the [guide for contributing to packages](https://github.com/atom/atom/blob/master/docs/contributing-to-packages.md) has some extra information. 26 | 27 | ## License 28 | MIT License. See [the license](LICENSE.md) for more details. 29 | -------------------------------------------------------------------------------- /lib/providers/actionProviderComposer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @name actionProviderComposer 3 | * @description a function that satisfies the Atom IDE CodeActionProvider 4 | * interface, running each diagnostic through a series of providers, each 5 | * of which may return either a valid CodeAction (optionally wrapped in a 6 | * Promise) as defined by Atom IDE or null if a given provider chooses not 7 | * to provide an action for the diagnostic 8 | * @param {JavaLanguageClient} languageClient the wrapping Java language client 9 | * @param {[function]} providers a list of providers to register 10 | * @returns {atom$CodeActionProvider} 11 | **/ 12 | function actionProviderComposer(languageClient, ...providers) { 13 | return { 14 | grammarScopes: [ 'source.java' ], 15 | priority: 1, 16 | getCodeActions(editor, range, diagnostics) { 17 | try { 18 | return Promise.all( 19 | diagnostics.reduce((acc, diagnostic) => 20 | acc.concat( 21 | providers 22 | .map(p => p(editor, range, diagnostic, languageClient)) 23 | .filter(action => !!action) 24 | .reduce((_acc, action) => { 25 | if (!Array.isArray(action)) action = [action] 26 | return _acc.concat(action) 27 | }, []) 28 | ) 29 | , []) 30 | ).catch(error => { languageClient.createNotification( 31 | 1, `Error getting code actions for diagnostic: ${error.toString()}` 32 | ) 33 | }) 34 | } catch (error) { 35 | languageClient.createNotification( 36 | 1, `Error getting code actions ${error.toString()}` 37 | ) 38 | } 39 | } 40 | } 41 | } 42 | 43 | module.exports = actionProviderComposer 44 | -------------------------------------------------------------------------------- /test/main.test.js: -------------------------------------------------------------------------------- 1 | const JavaLanguageClient = require('../lib/main.js') 2 | 3 | describe('main', () => { 4 | describe('parsing `Java --showVersion --version` output', () => { 5 | it('returns null for unmatched input', () => { 6 | const version = JavaLanguageClient.getJavaVersionFromOutput("my homemade java v1.9.2.1") 7 | expect(version).to.be.null 8 | }) 9 | 10 | it('returns 1.8 for Sun Java SE 1.8.0_40', () => { 11 | const version = JavaLanguageClient.getJavaVersionFromOutput('java version "1.8.0_40"' 12 | + '\nJava(TM) SE Runtime Environment (build 1.8.0_40-b27)' 13 | + '\nJava HotSpot(TM) 64-Bit Server VM (build 25.40-b25, mixed mode)') 14 | expect(version).to.be.equal(1.8) 15 | }) 16 | 17 | it('returns 1.8 for OpenJDK 1.8 (ubuntu)', () => { 18 | const version = JavaLanguageClient.getJavaVersionFromOutput('openjdk version "1.8.0_131"' 19 | + '\nOpenJDK Runtime Environment (build 1.8.0_131-8u131-b11-2ubuntu1.17.04.3-b11)' 20 | + '\nOpenJDK 64-Bit Server VM (build 25.131-b11, mixed mode)') 21 | expect(version).to.be.equal(1.8) 22 | }) 23 | 24 | it('returns 1.8 for OpenJDK 1.8 (custom)', () => { 25 | // #54 26 | const version = JavaLanguageClient.getJavaVersionFromOutput('openjdk version "1.8.0_172-solus"' 27 | + '\nOpenJDK Runtime Environment (build 1.8.0_172-solus-b00)' 28 | + '\nOpenJDK 64-Bit Server VM (build 25.172-b00, mixed mode)') 29 | expect(version).to.be.equal(1.8) 30 | }) 31 | 32 | it('returns 1.8 for OpenJDK 1.8 (macOS)', () => { 33 | // #62 34 | const version = JavaLanguageClient.getJavaVersionFromOutput('openjdk version "1.8.0_152-release"' 35 | + '\nOpenJDK Runtime Environment (build 1.8.0_152-release-915-b08)' 36 | + '\nOpenJDK 64-Bit Server VM (build 25.152-b08, mixed mode)') 37 | expect(version).to.be.equal(1.8) 38 | }) 39 | 40 | it('returns 8 for Sun OpenJDK 1.8', () => { 41 | const version = JavaLanguageClient.getJavaVersionFromOutput('java version "9"' 42 | + '\nJava(TM) SE Runtime Environment (build 9+181)' 43 | + '\nJava HotSpot(TM) 64-Bit Server VM (build 9+181, mixed mode)') 44 | expect(version).to.be.equal(9) 45 | }) 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v0.9.0 2 | 3 | - Update language server to 0.39.0 for signature help and many other improvements (#105), thanks @appelgriebsch 4 | - Code cleanup and make sure that ide-java continues working on newer Node versions (#97), thanks @Aerijo 5 | 6 | ## v0.8.3 7 | 8 | - Experimental support for Java extensions #66 thanks @boykoalex 9 | - Refocus select list after code actions #70 thanks @tylerfowler 10 | - Enable console logging service #73 thanks @tylerfowler 11 | - Update atom-languageclient to 0.9.5 12 | 13 | ## v0.8.2 14 | 15 | - Improve java version detection - thanks @Arcanemagus 16 | - Update atom-languageclient to 0.9.3 to address messages with buttons & console logging 17 | - Update language server to 0.15.0 for improved hover, config triggers, diagnostic ranges etc. 18 | 19 | ## v0.8.1 20 | 21 | - Update atom-languageclient to 0.9.1 to address missing trigger characters on some completions 22 | 23 | ## v0.8.0 24 | 25 | - Update language server to 0.12.1 for better editing and Java 9 support 26 | - Now link classpath message to our own wiki help instead of Eclipse's 27 | - Update atom-languageclient to 0.9.0 which includes 28 | - Superior autocomplete (min-char triggers, caching, filtering, resolve) 29 | - Server restart/recovery 30 | - Cancels outstanding autocomplete/outline requests when no longer needed 31 | 32 | ## v0.7.0 33 | 34 | - Update language server to 0.8.0 35 | - Update atom-languageclient to 0.7.0 which includes 36 | - Sorting autocomplete results, fixes #46 37 | - Snippet completion type items 38 | - Busy signals for startup and shutdown 39 | 40 | ## v0.6.8 41 | 42 | - Only add status bar tile after ide-java detects Java files 43 | 44 | ## v0.6.7 45 | 46 | - Update language server to v0.7.0 for better performance etc. 47 | 48 | ## v0.6.6 49 | 50 | - Fixed "Classpath is incomplete" error introduced in v0.6.4 caused by language server breaking LSP 2.0 support #37 51 | 52 | ## v0.6.5 53 | 54 | - Add Java 9 support 55 | - Status bar and process handle improvements 56 | 57 | ## v0.6.4 58 | 59 | - Ensure version detection compatible with openjdk 60 | 61 | ## v0.6.3 62 | 63 | - Change version detection logic to handle stderr and stdout 64 | 65 | ## v0.6.2 66 | 67 | - Update language server to 0.5.0 (fixes #8) 68 | - Display diagnostics error if language server exits unexpectedly 69 | - Remove warnings in console during install 70 | 71 | ## v0.6.1 72 | 73 | - Update language server to 0.3.0 #13 74 | - Update atom-languageclient for better diagnostics, source ordered OutlineView 75 | - Check Java runtime version at start-up 76 | - Create temporary directory for language server data #5 77 | - Documentation clean-up 78 | - Clean up and simplify download and install code 79 | 80 | ## v0.6.0 81 | 82 | - Initial release 83 | -------------------------------------------------------------------------------- /test/removeUnusedImportActionProvider.test.js: -------------------------------------------------------------------------------- 1 | const {pointsAreEqual} = require('./helpers/points.helper') 2 | const {Point} = require('atom') 3 | const {provider, isUnusedImport} = require('../lib/providers/removeUnusedImportActionProvider') 4 | 5 | describe('removeUnusedImportActionProvider', () => { 6 | describe('isUnusedImport', () => { 7 | it('should return true for unused class import messages', () => { 8 | const msg = { text: 'The import Map is never used' } 9 | expect(isUnusedImport(msg)).to.be.true 10 | }) 11 | 12 | it('should return true for unused constant import messages', () => { 13 | const msg = { text: 'The import SOME_CONSTANT is never used' } 14 | expect(isUnusedImport(msg)).to.be.true 15 | }) 16 | 17 | it('should return false for other error messages', () => { 18 | const msg = { text: 'Never used import Map' } 19 | expect(isUnusedImport(msg)).to.be.false 20 | }) 21 | }) 22 | 23 | describe('Code Action', () => { 24 | let editor 25 | beforeEach(() => atom.workspace.open().then(e => editor = e)) 26 | 27 | it('should delete the import', () => { 28 | const srcText = ` 29 | package com.example; 30 | 31 | import com.example.SomeUsedImport; 32 | import com.example.SomeUnusedImport; 33 | import com.example.SomeOtherUsedImport; 34 | 35 | public class MyClass {} 36 | ` 37 | 38 | const expectedText = ` 39 | package com.example; 40 | 41 | import com.example.SomeUsedImport; 42 | import com.example.SomeOtherUsedImport; 43 | 44 | public class MyClass {} 45 | ` 46 | 47 | editor.setText(srcText) 48 | const importRange = { start: [4, 2], end: [4, 38] } 49 | const diagnostic = { text: 'The import SomeUnusedImport is never used' } 50 | 51 | const action = provider(editor, importRange, diagnostic) 52 | expect(action).not.to.be.null 53 | 54 | return action.apply() 55 | .then(() => expect(editor.getText()).to.equal(expectedText)) 56 | }) 57 | 58 | it('should return the cursor to its previous position', () => { 59 | const srcText = ` 60 | package com.example; 61 | 62 | import com.example.SomeUnusedImport; 63 | ` 64 | 65 | editor.setText(srcText) 66 | 67 | const cursorPos = new Point(1, 0) 68 | editor.setCursorBufferPosition(cursorPos) 69 | 70 | const importRange = { start: [3, 2], end: [3, 38] } 71 | const diagnostic = { text: 'The import SomeUnusedImport is never used' } 72 | 73 | const action = provider(editor, importRange, diagnostic) 74 | expect(action).not.to.be.null 75 | 76 | return action.apply() 77 | .then(() => expect( 78 | pointsAreEqual( 79 | Object.assign({}, cursorPos, { row: cursorPos.row - 1 }), 80 | editor.getCursorBufferPosition() 81 | ) 82 | ).to.be.true 83 | ) 84 | }) 85 | }) 86 | }) 87 | -------------------------------------------------------------------------------- /test/actionProviderComposer.test.js: -------------------------------------------------------------------------------- 1 | const actionProviderComposer = require('../lib/providers/actionProviderComposer') 2 | 3 | const makeCodeAction = (title, result) => ({ 4 | getTitle() { return title }, 5 | dispose() {}, 6 | apply() { return result } 7 | }) 8 | 9 | const diagnosticMock = { text: 'Some diagnostic' } 10 | const buildLanguageClientNotifierMock = errCb => ({ 11 | createNotification: (severity, err) => done(errCb) 12 | }) 13 | 14 | describe('actionProviderComposer', done => { 15 | it('provides diagnostics to providers', done => { 16 | const provider = idx => (_, __, diagnostic, cl) => { 17 | expect(diagnostic).to.be.equal(diagnosticMock) 18 | 19 | return [ makeCodeAction(`test${idx}`, Promise.resolve()) ] 20 | } 21 | 22 | actionProviderComposer(buildLanguageClientNotifierMock(done), provider(1), provider(2)) 23 | .getCodeActions(null, null, [diagnosticMock]) 24 | .then(actions => { 25 | expect(actions).not.to.be.null 26 | expect(actions.length).to.equal(2) 27 | expect(actions[0].getTitle()).to.equal('test1') 28 | done() 29 | }).catch(err => done(err)) 30 | }) 31 | 32 | it('accepts non-array code actions', done => { 33 | const provider = (_, __, diagnostic, cl) => { 34 | return makeCodeAction('test', Promise.resolve()) 35 | } 36 | 37 | actionProviderComposer(buildLanguageClientNotifierMock(done), provider) 38 | .getCodeActions(null, null, [diagnosticMock]) 39 | .then(actions => { 40 | expect(actions).not.to.be.null 41 | expect(actions.length).to.equal(1) 42 | expect(actions[0].getTitle()).to.equal('test') 43 | done() 44 | }).catch(err => done(err)) 45 | }) 46 | 47 | it('accepts promises containing actions', done => { 48 | const provider = (_, __, diagnostic, cl) => { 49 | return [ makeCodeAction('promise test', Promise.resolve(true)) ] 50 | } 51 | 52 | actionProviderComposer(buildLanguageClientNotifierMock(done), provider) 53 | .getCodeActions(null, null, [diagnosticMock]) 54 | .then(() => done()) 55 | .catch(err => done(err)) 56 | }) 57 | 58 | it('cleans all null results', done => { 59 | const provider = (_, __, diagnostic, cl) => { return null } 60 | 61 | actionProviderComposer(buildLanguageClientNotifierMock(done), provider) 62 | .getCodeActions(null, null, [diagnosticMock]) 63 | .then(() => done()) 64 | .catch(err => done(err)) 65 | }) 66 | 67 | it('reports errors thrown from providers', done => { 68 | const languageClientMock = { createNotification: (_, err) => { 69 | expect(err).not.to.be.null 70 | done() 71 | }} 72 | 73 | const provider = () => { throw new Error('Provider creation error') } 74 | 75 | actionProviderComposer(languageClientMock, provider) 76 | .getCodeActions(null, null, [diagnosticMock]) 77 | .catch(err => done(err)) 78 | }) 79 | 80 | it('reports errors from providers with rejected promises', done => { 81 | const languageClientMock = { createNotification: (_, err) => { 82 | expect(err).not.to.be.null 83 | done() 84 | }} 85 | 86 | const provider = () => Promise.reject('Action creation error') 87 | 88 | actionProviderComposer(languageClientMock, provider) 89 | .getCodeActions(null, null, [diagnosticMock]) 90 | .catch(err => done(err)) 91 | }) 92 | 93 | it('calls through with no providers', () => { 94 | actionProviderComposer(buildLanguageClientNotifierMock(done)) 95 | .getCodeActions(null, null, [diagnosticMock]) 96 | .then(actions => { 97 | expect(actions).not.to.be.null 98 | expect(actions.length).to.equal(0) 99 | done() 100 | }).catch(err => done(err)) 101 | }) 102 | }) 103 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | server/ 2 | package-lock.json 3 | 4 | # Created by https://www.gitignore.io/api/vim,node,macos,linux,emacs,windows 5 | 6 | ### Emacs ### 7 | # -*- mode: gitignore; -*- 8 | *~ 9 | \#*\# 10 | /.emacs.desktop 11 | /.emacs.desktop.lock 12 | *.elc 13 | auto-save-list 14 | tramp 15 | .\#* 16 | 17 | # Org-mode 18 | .org-id-locations 19 | *_archive 20 | 21 | # flymake-mode 22 | *_flymake.* 23 | 24 | # eshell files 25 | /eshell/history 26 | /eshell/lastdir 27 | 28 | # elpa packages 29 | /elpa/ 30 | 31 | # reftex files 32 | *.rel 33 | 34 | # AUCTeX auto folder 35 | /auto/ 36 | 37 | # cask packages 38 | .cask/ 39 | dist/ 40 | 41 | # Flycheck 42 | flycheck_*.el 43 | 44 | # server auth directory 45 | /server/ 46 | 47 | # projectiles files 48 | .projectile 49 | projectile-bookmarks.eld 50 | 51 | # directory configuration 52 | .dir-locals.el 53 | 54 | # saveplace 55 | places 56 | 57 | # url cache 58 | url/cache/ 59 | 60 | # cedet 61 | ede-projects.el 62 | 63 | # smex 64 | smex-items 65 | 66 | # company-statistics 67 | company-statistics-cache.el 68 | 69 | # anaconda-mode 70 | anaconda-mode/ 71 | 72 | ### Linux ### 73 | 74 | # temporary files which can be created if a process still has a handle open of a deleted file 75 | .fuse_hidden* 76 | 77 | # KDE directory preferences 78 | .directory 79 | 80 | # Linux trash folder which might appear on any partition or disk 81 | .Trash-* 82 | 83 | # .nfs files are created when an open file is removed but is still being accessed 84 | .nfs* 85 | 86 | ### macOS ### 87 | *.DS_Store 88 | .AppleDouble 89 | .LSOverride 90 | 91 | # Icon must end with two \r 92 | Icon 93 | 94 | # Thumbnails 95 | ._* 96 | 97 | # Files that might appear in the root of a volume 98 | .DocumentRevisions-V100 99 | .fseventsd 100 | .Spotlight-V100 101 | .TemporaryItems 102 | .Trashes 103 | .VolumeIcon.icns 104 | .com.apple.timemachine.donotpresent 105 | 106 | # Directories potentially created on remote AFP share 107 | .AppleDB 108 | .AppleDesktop 109 | Network Trash Folder 110 | Temporary Items 111 | .apdisk 112 | 113 | ### Node ### 114 | # Logs 115 | logs 116 | *.log 117 | npm-debug.log* 118 | yarn-debug.log* 119 | yarn-error.log* 120 | 121 | # Runtime data 122 | pids 123 | *.pid 124 | *.seed 125 | *.pid.lock 126 | 127 | # Directory for instrumented libs generated by jscoverage/JSCover 128 | lib-cov 129 | 130 | # Coverage directory used by tools like istanbul 131 | coverage 132 | 133 | # nyc test coverage 134 | .nyc_output 135 | 136 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 137 | .grunt 138 | 139 | # Bower dependency directory (https://bower.io/) 140 | bower_components 141 | 142 | # node-waf configuration 143 | .lock-wscript 144 | 145 | # Compiled binary addons (http://nodejs.org/api/addons.html) 146 | build/Release 147 | 148 | # Dependency directories 149 | node_modules/ 150 | jspm_packages/ 151 | 152 | # Typescript v1 declaration files 153 | typings/ 154 | 155 | # Optional npm cache directory 156 | .npm 157 | 158 | # Optional eslint cache 159 | .eslintcache 160 | 161 | # Optional REPL history 162 | .node_repl_history 163 | 164 | # Output of 'npm pack' 165 | *.tgz 166 | 167 | # Yarn Integrity file 168 | .yarn-integrity 169 | 170 | # dotenv environment variables file 171 | .env 172 | 173 | 174 | ### Vim ### 175 | # swap 176 | [._]*.s[a-v][a-z] 177 | [._]*.sw[a-p] 178 | [._]s[a-v][a-z] 179 | [._]sw[a-p] 180 | # session 181 | Session.vim 182 | # temporary 183 | .netrwhist 184 | # auto-generated tag files 185 | tags 186 | 187 | ### Windows ### 188 | # Windows thumbnail cache files 189 | Thumbs.db 190 | ehthumbs.db 191 | ehthumbs_vista.db 192 | 193 | # Folder config file 194 | Desktop.ini 195 | 196 | # Recycle Bin used on file shares 197 | $RECYCLE.BIN/ 198 | 199 | # Windows Installer files 200 | *.cab 201 | *.msi 202 | *.msm 203 | *.msp 204 | 205 | # Windows shortcuts 206 | *.lnk 207 | 208 | # End of https://www.gitignore.io/api/vim,node,macos,linux,emacs,windows 209 | 210 | -------------------------------------------------------------------------------- /lib/providers/autoImportActionProvider.js: -------------------------------------------------------------------------------- 1 | const ImportSuggestionListView = require('../views/importSuggestionListView') 2 | 3 | function autoImportActionProvider(editor, range, diagnostic, languageClient) { 4 | if (!isCandidateForAutoImport(diagnostic)) return 5 | const missingType = getUnimportedType(diagnostic) 6 | 7 | return { 8 | getTitle() { return Promise.resolve(`Add import for ${missingType}`) }, 9 | dispose() {}, 10 | apply() { 11 | return getImportChoice(missingType, editor, range, languageClient) 12 | .then(chosenImport => { 13 | if (chosenImport) return addImport(editor, chosenImport) 14 | else languageClient.createNotification(2, 'No import suggestions found') 15 | }) 16 | .catch(error => languageClient.createNotification( 17 | 1, `Error performing autoimport: ${error.message}`, {} 18 | )) 19 | } 20 | } 21 | } 22 | 23 | /** 24 | * @name addImport 25 | * @description finds an appropriate location to insert a Java import statement, 26 | * will first check for the location of other import statements and will add 27 | * the import to a new line above other statements, otherwise will add it to 28 | * a full new line after the top level package statement 29 | * @param {atom$TextEditor} editor the editor of the document the import is to be added to 30 | * @param {String} importPkg the fully qualified Java package and type to import 31 | * @returns {Promise} 32 | * @todo handle static imports 33 | **/ 34 | function addImport(editor, importPkg) { 35 | let importInsertionRange 36 | let shouldInsertBelow = false 37 | 38 | // get first occurrence of an import 39 | editor.scan(/import.+$/, result => { 40 | importInsertionRange = result.range || result.computedRange 41 | stop() 42 | }) 43 | 44 | // if there are no imports place under `package` declaration 45 | if (!importInsertionRange) { 46 | editor.scan(/package.+$/, result => { 47 | importInsertionRange = result.range || result.computedRange 48 | shouldInsertBelow = true 49 | stop() 50 | }) 51 | } 52 | 53 | if (!importInsertionRange) 54 | throw new Error('No suitable location for import found') 55 | 56 | const curCursorPos = editor.getCursorBufferPosition() 57 | editor.setCursorBufferPosition(importInsertionRange.start) 58 | 59 | let numLinesOffset; 60 | if (shouldInsertBelow) { 61 | // if inserting below ensure we clear a line above the import stmt 62 | editor.insertNewlineBelow() 63 | editor.insertNewlineBelow() 64 | numLinesOffset = 2 65 | } else { 66 | editor.insertNewlineAbove() 67 | numLinesOffset = 1 68 | } 69 | 70 | editor.insertText(`import ${importPkg};`, { autoIndent: true }) 71 | editor.setCursorBufferPosition({ 72 | row: curCursorPos.row + numLinesOffset, 73 | column: curCursorPos.column 74 | }) 75 | } 76 | 77 | /** 78 | * @name getImportChoice 79 | * @description retrieves a list of import suggestions for the given missing type, 80 | * if there are multiple suggestions the user will be prompted to choose a specific 81 | * import 82 | * @param {String} missingType the Java type name being looked for 83 | * @param {TextEditor} editor 84 | * @param {atom$Range} typeRange the buffer range of the type name 85 | * @param {AtomLanguageClient} languageClient the parent AtomLanguageClient 86 | * @returns {Promise} a promise resolving to the package address of the import, 87 | * null signifies user cancellation 88 | **/ 89 | function getImportChoice(missingType, editor, typeRange, languageClient) { 90 | const autocompleteRequest = { 91 | editor, 92 | bufferPosition: typeRange.end, 93 | scopeDescriptor: editor.getRootScopeDescriptor().scopes[0], 94 | prefix: missingType, 95 | activatedManually: true 96 | } 97 | 98 | return languageClient.getSuggestions(autocompleteRequest) 99 | .then(ss => ss 100 | .filter(({ text }) => text === missingType) 101 | .map(buildImportSuggestion) 102 | // duplicate suggestions are sometimes returned for 103 | // different forms of a function call so dedupe 104 | .reduce((acc, nextImport) => { 105 | if (!~acc.findIndex(itm => itm === nextImport)) acc.push(nextImport) 106 | return acc 107 | }, []) 108 | .filter(i => !!i) 109 | ).then(possibleImports => { 110 | if (possibleImports.length === 0) return 111 | if (possibleImports.length === 1) return possibleImports[0] 112 | 113 | let view = new ImportSuggestionListView(possibleImports) 114 | return view.waitForConfirmOrCancel() 115 | }) 116 | } 117 | 118 | function buildImportSuggestion(autocompleteSuggestion) { 119 | switch (autocompleteSuggestion.type) { 120 | case 'class': 121 | case 'enum': 122 | const { displayText } = autocompleteSuggestion 123 | const [ typeName, pkgName ] = displayText.split('-').map(s => s.trim()) 124 | 125 | if (!typeName || !pkgName) 126 | throw new Error(`Not enough parts in autocomplete suggestion to make import suggestion: ${displayText}`) 127 | 128 | return `${pkgName}.${typeName}` 129 | case 'function': 130 | return autocompleteSuggestion.leftLabel || autocompleteSuggestion.rightLabel; 131 | default: 132 | throw new Error(`Unknown autocomplete suggestion type ${autocompleteSuggestion.type}`) 133 | } 134 | } 135 | 136 | function getUnimportedType({ text }) { 137 | const typeNameExpr = /^[a-zA-Z0-9_]{0,}(?=\ )/ig 138 | const typeNameMatch = typeNameExpr.exec(text) 139 | 140 | if (!typeNameMatch) return '' 141 | 142 | const [ typeName ] = typeNameMatch 143 | return typeName 144 | } 145 | 146 | function isCandidateForAutoImport({ text }) { 147 | const testExpr = /^(?!The import).+ cannot be resolved[ to a (type|variable)]*/ig 148 | return testExpr.test(text) 149 | } 150 | 151 | module.exports = { 152 | provider: autoImportActionProvider, 153 | addImport, getImportChoice, getUnimportedType, 154 | buildImportSuggestion, isCandidateForAutoImport 155 | } 156 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ide-java", 3 | "main": "./lib/main", 4 | "version": "0.9.1", 5 | "description": "Java language support for Atom-IDE.", 6 | "repository": "https://github.com/atom/ide-java", 7 | "license": "MIT", 8 | "scripts": { 9 | "test": "atom --test test" 10 | }, 11 | "engines": { 12 | "atom": ">=1.21.0" 13 | }, 14 | "configSchema": { 15 | "javaHome": { 16 | "order": 10, 17 | "type": "string", 18 | "default": "", 19 | "description": "Absolute path to Java 8 or later home folder used to launch the Java language server." 20 | }, 21 | "virtualMachine": { 22 | "order": 20, 23 | "type": "object", 24 | "properties": { 25 | "extraArgs": { 26 | "type": "string", 27 | "default": "-noverify -Xmx1G -XX:+UseG1GC -XX:+UseStringDeduplication", 28 | "description": "Extra arguments passed to the Java VM when launching the Java Language Server. Eg. use `-noverify -Xmx1G -XX:+UseG1GC -XX:+UseStringDeduplication` to bypass class verification, increase the heap size to 1GB and enable String deduplication with the G1 Garbage collector." 29 | } 30 | } 31 | }, 32 | "server": { 33 | "order": 30, 34 | "type": "object", 35 | "title": "Language Server", 36 | "description": "Settings that control language server functionality.", 37 | "properties": { 38 | "configuration": { 39 | "type": "object", 40 | "title": "Project Configuration", 41 | "properties": { 42 | "updateBuildConfiguration": { 43 | "type": "string", 44 | "title": "Update Build Configuration", 45 | "enum": [ 46 | { 47 | "value": "disabled", 48 | "description": "Never" 49 | }, 50 | { 51 | "value": "interactive", 52 | "description": "Ask every time" 53 | }, 54 | { 55 | "value": "automatic", 56 | "description": "Always" 57 | } 58 | ], 59 | "default": "interactive", 60 | "description": "Whether to automatically update the project configuration when build files change." 61 | } 62 | } 63 | }, 64 | "signatureHelp": { 65 | "type": "object", 66 | "title": "Signature Help", 67 | "properties": { 68 | "enabled": { 69 | "type": "boolean", 70 | "title": "Enabled", 71 | "default": true, 72 | "description": "Controls whether signature help is enabled." 73 | } 74 | } 75 | }, 76 | "errors": { 77 | "type": "object", 78 | "title": "Warnings and Errors", 79 | "properties": { 80 | "incompleteClasspath": { 81 | "type": "object", 82 | "title": "Incomplete Classpath", 83 | "properties": { 84 | "severity": { 85 | "type": "string", 86 | "title": "Incomplete Classpath Severity", 87 | "enum": [ 88 | "ignore", 89 | "info", 90 | "warning", 91 | "error" 92 | ], 93 | "default": "warning", 94 | "description": "Severity of the message when the classpath is incomplete for a Java file." 95 | } 96 | } 97 | } 98 | } 99 | } 100 | } 101 | } 102 | }, 103 | "atomTestRunner": "./test/runner", 104 | "dependencies": { 105 | "atom-languageclient": "0.9.9", 106 | "atom-select-list": "^0.7.1", 107 | "decompress": "^4.2.0" 108 | }, 109 | "devDependencies": { 110 | "atom-mocha-test-runner": "^1.0.1", 111 | "chai": "^3.5.0", 112 | "chai-as-promised": "^5.3.0", 113 | "mocha": "^3.4.2", 114 | "mocha-appveyor-reporter": "^0.4.0", 115 | "mocha-junit-and-console-reporter": "^1.6.0" 116 | }, 117 | "enhancedScopes": [ 118 | "source.java" 119 | ], 120 | "consumedServices": { 121 | "linter-indie": { 122 | "versions": { 123 | "2.0.0": "consumeLinterV2" 124 | } 125 | }, 126 | "atom-ide-busy-signal": { 127 | "versions": { 128 | "0.1.0": "consumeBusySignal" 129 | } 130 | }, 131 | "console": { 132 | "versions": { 133 | "0.1.0": "consumeConsole" 134 | } 135 | }, 136 | "datatip": { 137 | "versions": { 138 | "0.1.0": "consumeDatatip" 139 | } 140 | }, 141 | "signature-help": { 142 | "versions": { 143 | "0.1.0": "consumeSignatureHelp" 144 | } 145 | }, 146 | "status-bar": { 147 | "versions": { 148 | "^1.0.0": "consumeStatusBar" 149 | } 150 | } 151 | }, 152 | "providedServices": { 153 | "autocomplete.provider": { 154 | "versions": { 155 | "2.0.0": "provideAutocomplete" 156 | } 157 | }, 158 | "code-format.range": { 159 | "versions": { 160 | "0.1.0": "provideCodeFormat" 161 | } 162 | }, 163 | "code-highlight": { 164 | "versions": { 165 | "0.1.0": "provideCodeHighlight" 166 | } 167 | }, 168 | "definitions": { 169 | "versions": { 170 | "0.1.0": "provideDefinitions" 171 | } 172 | }, 173 | "find-references": { 174 | "versions": { 175 | "0.1.0": "provideFindReferences" 176 | } 177 | }, 178 | "outline-view": { 179 | "versions": { 180 | "0.1.0": "provideOutlines" 181 | } 182 | }, 183 | "code-actions": { 184 | "versions": { 185 | "0.1.0": "provideCodeActions" 186 | } 187 | } 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /test/autoImportActionProvider.test.js: -------------------------------------------------------------------------------- 1 | const {pointsAreEqual} = require('./helpers/points.helper') 2 | const { 3 | provider, addImport, getImportChoice, getUnimportedType, 4 | buildImportSuggestion, isCandidateForAutoImport 5 | } = require('../lib/providers/autoImportActionProvider') 6 | 7 | describe.only('autoImportActionProvider', () => { 8 | describe('isCandidateForAutoImport', () => { 9 | it('should return true for class resolution errors', () => { 10 | const text = 'SomeClass cannot be resolved' 11 | expect(isCandidateForAutoImport({text})).to.be.true 12 | }) 13 | 14 | it('should return true for type resolution errors', () => { 15 | const text = 'SomeType cannot be resolved to a type' 16 | expect(isCandidateForAutoImport({text})).to.be.true 17 | }) 18 | 19 | it('should return true for variable resolution errors', () => { 20 | const text = 'SOME_VAR cannot be resolved to a variable' 21 | expect(isCandidateForAutoImport({text})).to.be.true 22 | }) 23 | 24 | it('should return false for imports that cannot be resolved', () => { 25 | const text = 'The import javax cannot be resolved' 26 | expect(isCandidateForAutoImport({text})).to.be.false 27 | }) 28 | }) 29 | 30 | describe('getUnimportedType', () => { 31 | it('should return the full type name', () => { 32 | const text = 'SomeClass cannot be resolved' 33 | expect(getUnimportedType({text})).to.equal('SomeClass') 34 | }) 35 | 36 | it('should return empty string if no match was found', () => { 37 | const text = 'invaliderrormessage' 38 | expect(getUnimportedType({text})).to.equal('') 39 | }) 40 | }) 41 | 42 | describe('buildImportSuggestion', () => { 43 | describe('Suggestion Type: `class`', () => { 44 | it('should build the correct import package path', () => { 45 | const suggestion = { 46 | type: 'class', 47 | displayText: 'Map - java.util' 48 | } 49 | 50 | expect(buildImportSuggestion(suggestion)).to.equal('java.util.Map') 51 | }) 52 | 53 | it('should throw error if type or package name is missing', () => { 54 | try { 55 | expect.fail('Should have thrown error') 56 | } catch (error) { 57 | expect(error).not.to.be.null 58 | } 59 | }) 60 | }) 61 | 62 | describe('Suggestion Type: `function`', () => { 63 | it('should return the correct import package path', () => { 64 | const suggestion = { 65 | type: 'function', 66 | leftLabel: 'com.example.MyException' 67 | } 68 | 69 | expect(buildImportSuggestion(suggestion)).to.equal('com.example.MyException') 70 | }) 71 | 72 | it('should throw error if `leftLabel` property is missing', () => { 73 | try { 74 | expect.fail('Should have thrown error') 75 | } catch (error) { 76 | expect(error).not.to.be.null 77 | } 78 | }) 79 | }) 80 | 81 | describe('Suggestion Type: `enum`', () => { 82 | it('should build the correct import package path', () => { 83 | const suggestion = { 84 | type: 'enum', 85 | displayText: 'Month - java.time' 86 | } 87 | 88 | expect(buildImportSuggestion(suggestion)).to.equal('java.time.Month') 89 | }) 90 | 91 | it('should throw error if type or package name is missing', () => { 92 | try { 93 | expect.fail('Should have thrown error') 94 | } catch (error) { 95 | expect(error).not.to.be.null 96 | } 97 | }) 98 | }) 99 | 100 | it('should throw error for unknown suggestion type', () => { 101 | try { 102 | buildImportSuggestion({ type: 'unknown type' }) 103 | expect.fail('Should have thrown error') 104 | } catch (error) { 105 | expect(error).not.to.be.null 106 | } 107 | }) 108 | }) 109 | 110 | // TODO test the multichoice UI component, somehow 111 | describe('getImportChoice', () => { 112 | const range = { start: [0, 0], end: [0, 0] } 113 | const editorMock = { getRootScopeDescriptor: () => ({ scopes: [ 'source.java' ] }) } 114 | const makeLanguageClientWithSuggestions = (...suggestions) => ({ 115 | getSuggestions(autocompleteRequest) { return Promise.resolve(suggestions) } 116 | }) 117 | 118 | it('should return an import suggestion w/o prompting the user if there is only one', () => { 119 | const client = makeLanguageClientWithSuggestions( 120 | { 121 | text: 'Map', 122 | type: 'class', 123 | displayText: 'Map - java.util' 124 | }, 125 | { 126 | text: 'Optional', 127 | type: 'class', 128 | displayText: 'Optional - java.util' 129 | } 130 | ) 131 | 132 | return getImportChoice('Map', editorMock, range, client) 133 | .then(chosenImport => expect(chosenImport).to.equal('java.util.Map')) 134 | }) 135 | 136 | it('should dedupe suggestions', () => { 137 | const client = makeLanguageClientWithSuggestions( 138 | { 139 | text: 'MyException', 140 | type: 'function', 141 | leftLabel: 'com.example.MyException', 142 | displayText: 'MyException()' 143 | }, 144 | { 145 | text: 'MyException', 146 | type: 'function', 147 | leftLabel: 'com.example.MyException', 148 | displayText: 'MyException(String msg)' 149 | } 150 | ) 151 | 152 | return getImportChoice('MyException', editorMock, range, client) 153 | .then(chosenImport => expect(chosenImport).to.equal('com.example.MyException')) 154 | }) 155 | 156 | it('should only suggest exact import matches', () => { 157 | const client = makeLanguageClientWithSuggestions( 158 | { 159 | text: 'HashMap', 160 | type: 'class', 161 | displayText: 'HashMap - java.util' 162 | }, 163 | { 164 | text: 'Map', 165 | type: 'class', 166 | displayText: 'Map - java.util' 167 | } 168 | ) 169 | 170 | return getImportChoice('HashMap', editorMock, range, client) 171 | .then(chosenImport => expect(chosenImport).to.equal('java.util.HashMap')) 172 | }) 173 | 174 | it('should return nothing if no suggestions are found', () => { 175 | const client = makeLanguageClientWithSuggestions() 176 | 177 | return getImportChoice('HashMap', editorMock, range, client) 178 | .then(chosenImport => expect(chosenImport).not.to.be.defined) 179 | }) 180 | }) 181 | 182 | describe('addImport', () => { 183 | let editor 184 | beforeEach(() => atom.workspace.open().then(e => editor = e)) 185 | 186 | it('should place imports above other imports', () => { 187 | const pkg = 'java.util.Optional' 188 | const editorText = ` 189 | package com.example; 190 | 191 | import java.util.Map; 192 | ` 193 | const expectedText = ` 194 | package com.example; 195 | 196 | import ${pkg}; 197 | import java.util.Map; 198 | ` 199 | 200 | editor.setText(editorText) 201 | addImport(editor, pkg) 202 | 203 | expect(editor.getText()).to.equal(expectedText) 204 | }) 205 | 206 | it('should place imports below package if no imports exist', () => { 207 | const pkg = 'java.util.Optional' 208 | const editorText = ` 209 | package com.example; 210 | ` 211 | 212 | const expectedText = '\npackage com.example;' 213 | + `\n\nimport ${pkg};\n` 214 | 215 | editor.setText(editorText) 216 | addImport(editor, pkg) 217 | 218 | expect(editor.getText()).to.equal(expectedText) 219 | }) 220 | 221 | it('should throw error if there are no imports or package statement', () => { 222 | try { 223 | editor.setText('') 224 | addImport(editor, 'java.util.Optional') 225 | expect.fail('should have thrown error') 226 | } catch (error) { 227 | expect(error).not.to.be.null 228 | } 229 | }) 230 | 231 | it('should retain users cursor position', () => { 232 | const pkg = 'java.util.Optional' 233 | const editorText = ` 234 | package com.example; 235 | 236 | public class MyClass {} 237 | ` 238 | 239 | editor.setText(editorText) 240 | 241 | editor.setCursorBufferPosition({ row: 3, column: 15 }) 242 | const curWord = editor.getWordUnderCursor() 243 | 244 | addImport(editor, pkg) 245 | expect(editor.getWordUnderCursor()).to.equal(curWord) 246 | }) 247 | }) 248 | 249 | describe('Code Action', () => { 250 | const makeLanguageClientWithSuggestions = (failcb, ...suggestions) => ({ 251 | getSuggestions(autocompleteRequest) { return Promise.resolve(suggestions) }, 252 | createNotification(_, errMsg, opts) { return failcb(errMsg) } 253 | }) 254 | 255 | let editor 256 | beforeEach(() => atom.workspace.open().then(e => editor = e)) 257 | 258 | it('should insert found import', done => { 259 | const client = makeLanguageClientWithSuggestions( 260 | done, { 261 | text: 'Map', 262 | type: 'class', 263 | displayText: 'Map - java.util' 264 | }, 265 | { 266 | text: 'HashMap', 267 | type: 'class', 268 | displayText: 'HashMap - java.util' 269 | } 270 | ) 271 | 272 | const editorText = ` 273 | package com.example; 274 | 275 | import java.util.Map; 276 | 277 | public class MyClass { 278 | private Map m; 279 | public MyClass() { m = new HashMap<>() } 280 | } 281 | ` 282 | 283 | const expectedText = ` 284 | package com.example; 285 | 286 | import java.util.HashMap; 287 | import java.util.Map; 288 | 289 | public class MyClass { 290 | private Map m; 291 | public MyClass() { m = new HashMap<>() } 292 | } 293 | ` 294 | editor.setText(editorText) 295 | 296 | const range = { start: [7, 29], end: [7, 35] } 297 | const diagnostic = { text: 'HashMap cannot be resolved to a type' } 298 | 299 | editor.setCursorBufferPosition({ row: 7, column: 29 }) 300 | const wordUnderCursor = editor.getWordUnderCursor() 301 | 302 | provider(editor, range, diagnostic, client).apply() 303 | .then(() => { 304 | expect(editor.getText()).to.equal(expectedText) 305 | expect(editor.getWordUnderCursor()).to.equal(wordUnderCursor) 306 | done() 307 | }).catch(err => done(err)) 308 | }) 309 | 310 | it('should notify the user if no import suggestions were found', () => { 311 | const client = makeLanguageClientWithSuggestions(err => { 312 | expect(err).not.to.be.null 313 | expect(editor.getText()).to.equal(editorText) 314 | done() 315 | }) 316 | 317 | const editorText = ` 318 | package com.example; 319 | 320 | import java.util.Map; 321 | 322 | public class MyClass { 323 | private Map m; 324 | public MyClass() { m = new HashMap<>() } 325 | } 326 | ` 327 | 328 | editor.setText(editorText) 329 | 330 | const range = { start: [8, 31], end: [8, 37] } 331 | const diagnostic = { text: 'HashMap cannot be resolved to a type' } 332 | 333 | provider(editor, range, diagnostic, client).apply() 334 | .catch(err => done(err)) 335 | }) 336 | 337 | it('should notify the user if apply rejects', () => { 338 | const client = { 339 | createNotification(_, errMsg, {}) { 340 | expect(errMsg).not.to.be.null 341 | expect(editor.getText()).to.equal(editorText) 342 | done() 343 | }, 344 | getSuggestions(req) { return Promise.reject('Some error') } 345 | } 346 | 347 | const editorText = 'package com.example;' 348 | editor.setText(editorText) 349 | 350 | const range = { start: [0, 0], end: [0, 0] } 351 | const diagnostic = { text: 'HashMap cannot be resolved to a type' } 352 | 353 | provider(editor, range, diagnostic, client).apply() 354 | .catch(err => done(err)) 355 | }) 356 | }) 357 | }) 358 | -------------------------------------------------------------------------------- /lib/main.js: -------------------------------------------------------------------------------- 1 | const cp = require('child_process') 2 | const fs = require('fs') 3 | const os = require('os') 4 | const path = require('path') 5 | const {shell} = require('electron') 6 | const {AutoLanguageClient, DownloadFile} = require('atom-languageclient') 7 | 8 | const {actionProviderComposer, actionProviders} = require('./providers') 9 | const { 10 | autoImportProvider, 11 | removeUnusedImportProvider 12 | } = actionProviders 13 | 14 | const serverDownloadUrl = 'http://download.eclipse.org/jdtls/milestones/0.39.0/jdt-language-server-0.39.0-201905150127.tar.gz' 15 | const serverDownloadSize = 39070941 16 | const serverLauncher = '/plugins/org.eclipse.equinox.launcher_1.5.300.v20190213-1655.jar' 17 | const minJavaRuntime = 1.8 18 | const bytesToMegabytes = 1024 * 1024 19 | 20 | class JavaLanguageClient extends AutoLanguageClient { 21 | getGrammarScopes () { return [ 'source.java' ] } 22 | getLanguageName () { return 'Java' } 23 | getServerName () { return 'Eclipse JDT' } 24 | // List of preferences available at: 25 | // https://github.com/eclipse/eclipse.jdt.ls/blob/master/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/preferences/Preferences.java 26 | getRootConfigurationKey() { return 'ide-java.server' } 27 | mapConfigurationObject(configuration) { return {java: configuration} } 28 | 29 | constructor () { 30 | super() 31 | this.statusElement = document.createElement('span') 32 | this.statusElement.className = 'inline-block' 33 | 34 | this.commands = { 35 | 'java.ignoreIncompleteClasspath': () => { 36 | atom.config.set('ide-java.server.errors.incompleteClasspath.severity', 'ignore') 37 | }, 38 | 'java.ignoreIncompleteClasspath.help': () => { shell.openExternal('https://github.com/atom/ide-java/wiki/Incomplete-Classpath-Warning') }, 39 | 'java.projectConfiguration.status': (command, connection) => { 40 | // Arguments: 41 | // - 0: Object containing build file URI 42 | // - 1: 'disabled' for Never, 'interactive' for Now, 'automatic' for Always 43 | const [uri, status] = command.arguments 44 | const statusMap = { 45 | 0: 'disabled', 46 | 1: 'interactive', 47 | 2: 'automatic', 48 | } 49 | atom.config.set('ide-java.server.configuration.updateBuildConfiguration', statusMap[status]) 50 | 51 | if (status !== 0) { 52 | connection.sendCustomRequest('java/projectConfigurationUpdate', uri) 53 | } 54 | } 55 | } 56 | 57 | // Migrate ide-java.errors.incompleteClasspathSeverity -> ide-java.server.errors.incompleteClasspath.severity 58 | // Migration added in v0.10.0; feel free to remove after a few versions 59 | const severity = atom.config.get('ide-java.errors.incompleteClasspathSeverity') 60 | if (severity) { 61 | atom.config.unset('ide-java.errors.incompleteClasspathSeverity') 62 | atom.config.unset('ide-java.errors') 63 | atom.config.set('ide-java.server.errors.incompleteClasspath.severity', severity) 64 | } 65 | } 66 | 67 | startServerProcess (projectPath) { 68 | const config = { 'win32': 'win', 'darwin': 'mac', 'linux': 'linux' }[process.platform] 69 | if (config == null) { 70 | throw Error(`${this.getServerName()} not supported on ${process.platform}`) 71 | } 72 | 73 | const serverHome = path.join(__dirname, '..', 'server') 74 | const command = this.getJavaCommand() 75 | let javaVersion 76 | 77 | return this.checkJavaVersion(command) 78 | .then(foundJavaVersion => { 79 | javaVersion = foundJavaVersion 80 | return this.installServerIfRequired(serverHome) 81 | }) 82 | .then(() => this.getOrCreateDataDir(projectPath)) 83 | .then(dataDir => { 84 | const args = [] 85 | if (javaVersion >= 9) { 86 | args.push( 87 | '--add-modules=ALL-SYSTEM', 88 | '--add-opens', 'java.base/java.util=ALL-UNNAMED', 89 | '--add-opens', 'java.base/java.lang=ALL-UNNAMED' 90 | ) 91 | } 92 | 93 | const extraArgs = this.parseArgs(atom.config.get('ide-java.virtualMachine.extraArgs')) 94 | args.push(...extraArgs) 95 | 96 | args.push( 97 | '-jar', path.join(serverHome, serverLauncher), 98 | '-configuration', path.join(serverHome, `config_${config}`), 99 | '-data', dataDir 100 | ) 101 | 102 | this.logger.debug(`starting "${command} ${args.join(' ')}"`) 103 | const childProcess = cp.spawn(command, args, { cwd: serverHome }) 104 | this.captureServerErrors(childProcess) 105 | childProcess.on('close', exitCode => { 106 | if (!childProcess.killed) { 107 | atom.notifications.addError('IDE-Java language server stopped unexpectedly.', { 108 | dismissable: true, 109 | description: this.processStdErr ? `${this.processStdErr}` : `Exit code ${exitCode}` 110 | }) 111 | } 112 | this.updateStatusBar('Stopped') 113 | }) 114 | return childProcess 115 | } 116 | ) 117 | } 118 | 119 | checkJavaVersion (command) { 120 | return new Promise((resolve, reject) => { 121 | const childProcess = cp.spawn(command, [ '-showversion', '-version' ]) 122 | childProcess.on('error', err => { 123 | this.showJavaRequirements( 124 | 'IDE-Java could not launch your Java runtime.', 125 | err.code == 'ENOENT' 126 | ? `No Java runtime found at ${command}.` 127 | : `Could not spawn the Java runtime ${command}.` 128 | ) 129 | reject() 130 | }) 131 | let stdErr = '', stdOut = '' 132 | childProcess.stderr.on('data', chunk => stdErr += chunk.toString()) 133 | childProcess.stdout.on('data', chunk => stdOut += chunk.toString()) 134 | childProcess.on('close', exitCode => { 135 | const output = stdErr + '\n' + stdOut 136 | if (exitCode === 0 && output.length > 2) { 137 | const version = this.getJavaVersionFromOutput(output) 138 | if (version == null) { 139 | this.showJavaRequirements( 140 | `IDE-Java requires Java ${minJavaRuntime} but could not determine your Java version.`, 141 | `Could not parse the Java '--showVersion' output
${output}
.` 142 | ) 143 | reject() 144 | } 145 | if (version >= minJavaRuntime) { 146 | this.logger.debug(`Using Java ${version} from ${command}`) 147 | resolve(version) 148 | } else { 149 | this.showJavaRequirements( 150 | `IDE-Java requires Java ${minJavaRuntime} or later but found ${version}`, 151 | `If you have Java ${minJavaRuntime} installed please Set Java Path correctly. If you do not please Download Java ${minJavaRuntime} or later and install it.` 152 | ) 153 | reject() 154 | } 155 | } else { 156 | atom.notifications.addError('IDE-Java encounted an error using the Java runtime.', { 157 | dismissable: true, 158 | description: stdErr != '' ? `${stdErr}` : `Exit code ${exitCode}` 159 | }) 160 | reject() 161 | } 162 | }) 163 | }) 164 | } 165 | 166 | getJavaVersionFromOutput (output) { 167 | const match = output.match(/ version "(\d+(.\d+)?)(.\d+)?(_\d+)?(?:-\w+)?"/) 168 | return match != null && match.length > 0 ? Number(match[1]) : null 169 | } 170 | 171 | showJavaRequirements (title, description) { 172 | atom.notifications.addError(title, { 173 | dismissable: true, 174 | buttons: [ 175 | { text: 'Set Java Path', onDidClick: () => atom.workspace.open('atom://config/packages/ide-java') }, 176 | { text: 'Download Java', onDidClick: () => shell.openExternal('http://www.oracle.com/technetwork/java/javase/downloads/index.html') }, 177 | ], 178 | description: `${description}

If you have Java installed please Set Java Path correctly. If you do not please Download Java ${minJavaRuntime} or later and install it.

` 179 | }) 180 | } 181 | 182 | getJavaCommand () { 183 | const javaPath = this.getJavaPath() 184 | return javaPath == null ? 'java' : path.join(javaPath, 'bin', 'java') 185 | } 186 | 187 | getJavaPath () { 188 | return (new Array( 189 | atom.config.get('ide-java.javaHome'), 190 | process.env['JDK_HOME'], 191 | process.env['JAVA_HOME']) 192 | ).find(j => j) 193 | } 194 | 195 | getOrCreateDataDir (projectPath) { 196 | const dataDir = path.join(os.tmpdir(), `atom-java-${encodeURIComponent(projectPath)}`) 197 | return this.fileExists(dataDir) 198 | .then(exists => { if (!exists) fs.mkdirSync(dataDir, { recursive: true }) }) 199 | .then(() => dataDir) 200 | } 201 | 202 | installServerIfRequired (serverHome) { 203 | return this.isServerInstalled(serverHome) 204 | .then(doesExist => { if (!doesExist) return this.installServer(serverHome) }) 205 | } 206 | 207 | isServerInstalled (serverHome) { 208 | return this.fileExists(path.join(serverHome, serverLauncher)) 209 | } 210 | 211 | installServer (serverHome) { 212 | const localFileName = path.join(serverHome, 'download.tar.gz') 213 | const decompress = require('decompress') 214 | const provideInstallStatus = (bytesDone, percent) => { 215 | this.updateInstallStatus(`downloading ${Math.floor(serverDownloadSize / bytesToMegabytes)} MB (${percent}% done)`) 216 | } 217 | return this.fileExists(serverHome) 218 | .then(doesExist => { if (!doesExist) fs.mkdirSync(serverHome, { recursive: true }) }) 219 | .then(() => DownloadFile(serverDownloadUrl, localFileName, provideInstallStatus, serverDownloadSize)) 220 | .then(() => this.updateInstallStatus('unpacking')) 221 | .then(() => decompress(localFileName, serverHome)) 222 | .then(() => this.fileExists(path.join(serverHome, serverLauncher))) 223 | .then(doesExist => { if (!doesExist) throw Error(`Failed to install the ${this.getServerName()} language server`) }) 224 | .then(() => this.updateInstallStatus('installed')) 225 | .then(() => fs.unlinkSync(localFileName)) 226 | } 227 | 228 | preInitialization(connection) { 229 | let started = false 230 | connection.onCustom('language/status', (status) => { 231 | if (started) return 232 | this.updateStatusBar(status.message) 233 | // Additional messages can be generated after the server is ready 234 | // that we don't want to show (for example, build refreshes) 235 | if (status.type === 'Started') { 236 | started = true 237 | } 238 | }) 239 | connection.onCustom('language/actionableNotification', (notification) => this.actionableNotification(notification, connection)) 240 | } 241 | 242 | getInitializeParams(projectPath, process) { 243 | const params = super.getInitializeParams(projectPath, process); 244 | if (!params.initializationOptions) { 245 | params.initializationOptions = {}; 246 | } 247 | params.initializationOptions.bundles = this.collectJavaExtensions(); 248 | return params; 249 | } 250 | 251 | collectJavaExtensions() { 252 | return atom.packages.getLoadedPackages() 253 | .filter(pkg => Array.isArray(pkg.metadata.javaExtensions)) 254 | .map(pkg => pkg.metadata.javaExtensions.map(p => path.resolve(pkg.path, p))) 255 | .reduce(e => e.concat([]), []); 256 | } 257 | 258 | updateInstallStatus (status) { 259 | const isComplete = status === 'installed' 260 | if (this.busySignalService) { 261 | if (this._installSignal == null) { 262 | if (!isComplete) { 263 | this._installSignal = this.busySignalService.reportBusy(status, { revealTooltip: true }) 264 | } 265 | } else { 266 | if (isComplete) { 267 | this._installSignal.dispose() 268 | } else { 269 | this._installSignal.setTitle(status) 270 | } 271 | } 272 | } else { 273 | this.updateStatusBar(status) 274 | } 275 | } 276 | 277 | updateStatusBar (text) { 278 | this.statusElement.textContent = `${this.name} ${text}` 279 | if (!this.statusTile && this.statusBar) { 280 | this.statusTile = this.statusBar.addRightTile({ item: this.statusElement, priority: 1000 }) 281 | } 282 | } 283 | 284 | actionableNotification (notification, connection) { 285 | const options = { dismissable: true, detail: this.getServerName() } 286 | if (Array.isArray(notification.commands)) { 287 | options.buttons = notification.commands.map(command => ({ 288 | text: command.title, 289 | onDidClick: () => onActionableButton(command) 290 | })) 291 | } 292 | 293 | const notificationDialog = this.createNotification(notification.severity, notification.message, options) 294 | 295 | const onActionableButton = (command) => { 296 | const commandFunction = this.commands[command.command] 297 | if (commandFunction != null) { 298 | commandFunction(command, connection) 299 | } else { 300 | console.log(`Unknown actionableNotification command '${command.command}'`) 301 | } 302 | notificationDialog.dismiss() 303 | } 304 | } 305 | 306 | createNotification (severity, message, options) { 307 | switch (severity) { 308 | case 1: return atom.notifications.addError(message, options) 309 | case 2: return atom.notifications.addWarning(message, options) 310 | case 3: return atom.notifications.addInfo(message, options) 311 | case 4: console.log(message) 312 | } 313 | } 314 | 315 | consumeStatusBar (statusBar) { 316 | this.statusBar = statusBar 317 | } 318 | 319 | provideCodeActions() { 320 | return actionProviderComposer( 321 | this, 322 | autoImportProvider, 323 | removeUnusedImportProvider 324 | ) 325 | } 326 | 327 | fileExists (path) { 328 | return new Promise(resolve => { 329 | fs.access(path, fs.R_OK, error => { 330 | resolve(!error || error.code !== 'ENOENT') 331 | }) 332 | }) 333 | } 334 | 335 | deleteFileIfExists (path) { 336 | return new Promise((resolve, reject) => { 337 | fs.unlink(path, error => { 338 | if (error && error.code !== 'ENOENT') { reject(error) } else { resolve() } 339 | }) 340 | }) 341 | } 342 | 343 | parseArgs(argsLine) { 344 | if (!argsLine) return [] 345 | 346 | // Split the args into an array based on whitespace outside of double-quotes 347 | const args = argsLine.match(/(?:[^\s"]+|"[^"]*")+/g) 348 | if (args === null) return [] 349 | 350 | // Remove double quotes 351 | return args.map(arg => arg.replace(/(\\)?"/g, (a, b) => a ? b : '').replace(/(\\)"/g, '"')) 352 | } 353 | } 354 | 355 | module.exports = new JavaLanguageClient() 356 | --------------------------------------------------------------------------------