diff --git a/autoload/lsc/capabilities.vim b/autoload/lsc/capabilities.vim index 3fc35b22..21c03834 100644 --- a/autoload/lsc/capabilities.vim +++ b/autoload/lsc/capabilities.vim @@ -34,6 +34,21 @@ function! lsc#capabilities#normalize(capabilities) abort else let l:normalized.referenceHighlights = l:document_highlight_provider endif + if has_key(a:capabilities, 'workspace') + let l:workspace = a:capabilities.workspace + if has_key(l:workspace, 'workspaceFolders') + let l:workspace_folders = l:workspace.workspaceFolders + if has_key(l:workspace_folders, 'changeNotifications') + if type(l:workspace_folders.changeNotifications) == type(v:true) + let l:normalized.workspace.didChangeWorkspaceFolders = + \ l:workspace_folders.changeNotifications + else + " Does not handle deregistration + let l:normalized.workspace.didChangeWorkspaceFolders = v:true + endif + endif + endif + endif return l:normalized endfunction @@ -45,5 +60,8 @@ function! lsc#capabilities#defaults() abort \ 'sendDidSave': v:false, \ }, \ 'referenceHighlights': v:false, + \ 'workspace': { + \ 'didChangeWorkspaceFolders': v:false, + \ }, \} endfunction diff --git a/autoload/lsc/file.vim b/autoload/lsc/file.vim index bf1810e1..1f0eb266 100644 --- a/autoload/lsc/file.vim +++ b/autoload/lsc/file.vim @@ -40,7 +40,7 @@ function! lsc#file#onOpen() abort if l:server.status ==# 'running' call s:DidOpen(l:server, l:bufnr, l:file_path, &filetype) else - call lsc#server#start(l:server) + call lsc#server#start(l:server, l:file_path) endif endfor endif @@ -90,6 +90,7 @@ function! s:DidOpen(server, bufnr, file_path, filetype) abort \ } \ } if a:server.notify('textDocument/didOpen', l:params) + call s:UpdateRoots(a:server, a:file_path) let s:file_versions[a:file_path] = l:version if get(g:, 'lsc_enable_incremental_sync', v:true) \ && a:server.capabilities.textDocumentSync.incremental @@ -99,6 +100,28 @@ function! s:DidOpen(server, bufnr, file_path, filetype) abort endif endfunction +function! s:UpdateRoots(server, file_path) abort + if !has_key(a:server.config, 'WorkspaceRoot') | return | endif + if !a:server.capabilities.workspace.didChangeWorkspaceFolders | return | endif + try + let l:root = a:server.config.WorkspaceRoot(a:file_path) + catch + return + endtry + if index(a:server.roots, l:root) >= 0 | return | endif + call add(a:server.roots, l:root) + let l:workspace_folders = {'event': + \ {'added': [{ + \ 'uri': lsc#uri#documentUri(l:root), + \ 'name': fnamemodify(l:root, ':.'), + \ }], + \ 'removed': [], + \ }, + \ } + call a:server.notify('workspace/didChangeWorkspaceFolders', + \ l:workspace_folders) +endfunction + " Mark all files of type `filetype` as untracked. function! lsc#file#clean(filetype) abort for l:buffer in getbufinfo({'bufloaded': v:true}) diff --git a/autoload/lsc/server.vim b/autoload/lsc/server.vim index d1a2c199..493ecab4 100644 --- a/autoload/lsc/server.vim +++ b/autoload/lsc/server.vim @@ -11,6 +11,7 @@ if !exists('s:initialized') " - capabilities. Configuration for client/server interaction. " - filetypes. List of filetypes handled by this server. " - logs. The last 100 logs from `window/logMessage`. + " - roots. All workspace folders seen by this server. " - config. Config dict. Contains: " - name: Same as the key into `s:servers` " - command: Executable @@ -18,12 +19,14 @@ if !exists('s:initialized') " - message_hooks: (optional) Functions call to override params " - workspace_config: (optional) Arbitrary data to send as " `workspace/didChangeConfiguration` settings on startup. + " - WorkspaceRoot: (optional) Callback to discover the root of the project + " containing a given file path. let s:servers = {} let s:initialized = v:true endif -function! lsc#server#start(server) abort - call s:Start(a:server) +function! lsc#server#start(server, file_path) abort + call s:Start(a:server, a:file_path) endfunction function! lsc#server#status(filetype) abort @@ -99,9 +102,10 @@ function! lsc#server#restart() abort let l:server = s:servers[l:server_name] let l:old_status = l:server.status if l:old_status ==# 'starting' || l:old_status ==# 'running' + let l:server.started_from = lsc#file#fullPath() call s:Kill(l:server, 'restarting', v:null) else - call s:Start(l:server) + call s:Start(l:server, lsc#file#fullPath()) endif endfunction @@ -119,7 +123,7 @@ function! lsc#server#userCall(method, params, callback) abort endfunction " Start `server` if it isn't already running. -function! s:Start(server) abort +function! s:Start(server, file_path) abort if has_key(a:server, '_channel') " Server is already running return @@ -139,12 +143,30 @@ function! s:Start(server) abort else let l:trace_level = 'off' endif + try + let l:root = has_key(a:server.config, 'WorkspaceRoot') + \ ? a:server.config.WorkspaceRoot(a:file_path) + \ : lsc#file#cwd() + catch + call lsc#message#error( + \ 'Disabling workspace roots due to error: '.string(v:exception)) + let l:root = lsc#file#cwd() + unlet a:server.config.WorkspaceRoot + endtry + let a:server.roots = [l:root] + let l:capabilities = s:ClientCapabilities(a:server.config) let l:params = {'processId': getpid(), \ 'clientInfo': {'name': 'vim-lsc'}, - \ 'rootUri': lsc#uri#documentUri(lsc#file#cwd()), - \ 'capabilities': s:ClientCapabilities(), - \ 'trace': l:trace_level + \ 'rootUri': lsc#uri#documentUri(l:root), + \ 'capabilities': l:capabilities, + \ 'trace': l:trace_level, \} + if l:capabilities.workspace.workspaceFolders + let l:params.workspaceFolders = [{ + \ 'uri': lsc#uri#documentUri(l:root), + \ 'name': fnamemodify(l:root, ':.') + \ }] + endif call a:server._initialize(l:params, funcref('OnInitialize', [a:server])) endfunction @@ -164,7 +186,7 @@ function! s:OnInitialize(server, init_result) abort endfunction " Missing value means no support -function! s:ClientCapabilities() abort +function! s:ClientCapabilities(config) abort let l:applyEdit = v:false if !exists('g:lsc_enable_apply_edit') || g:lsc_enable_apply_edit let l:applyEdit = v:true @@ -173,6 +195,8 @@ function! s:ClientCapabilities() abort \ 'workspace': { \ 'applyEdit': l:applyEdit, \ 'configuration': v:true, + \ 'workspaceFolders': + \ has_key(a:config, 'WorkspaceRoot') ? v:true : v:false, \ }, \ 'textDocument': { \ 'synchronization': { @@ -221,7 +245,7 @@ function! lsc#server#enable() abort endif let l:server = s:servers[g:lsc_servers_by_filetype[&filetype]] let l:server.config.enabled = v:true - call s:Start(l:server) + call s:Start(l:server, lsc#file#fullPath()) endfunction function! lsc#server#register(filetype, config) abort @@ -294,6 +318,7 @@ function! lsc#server#register(filetype, config) abort endfunction function! l:server.on_exit() abort unlet l:self._channel + unlet l:self.roots let l:old_status = l:self.status if l:old_status ==# 'starting' let l:self.status= 'failed' @@ -315,7 +340,9 @@ function! lsc#server#register(filetype, config) abort call lsc#cursor#clean() endfor if l:old_status ==# 'restarting' - call s:Start(l:self) + let l:started_from = l:self.started_from + unlet l:self.started_from + call s:Start(l:self, l:started_from) endif endfunction function! l:server.find_config(item) abort diff --git a/autoload/lsc/workspace.vim b/autoload/lsc/workspace.vim new file mode 100644 index 00000000..3e709f67 --- /dev/null +++ b/autoload/lsc/workspace.vim @@ -0,0 +1,36 @@ +function! lsc#workspace#byMarker(markers) abort + return function('FindByMarkers', [a:markers]) +endfunction + +function! s:FindByMarkers(markers, file_path) abort + for l:path in s:ParentDirectories(a:file_path) + if s:ContainsAny(l:path, a:markers) | return l:path | endif + endfor + return fnamemodify(a:file_path, ':h') +endfunction + +" Whether `path` contains any children from `markers`. +function! s:ContainsAny(path, markers) abort + for l:marker in a:markers + if l:marker[-1:] ==# '/' + if isdirectory(a:path.'/'.l:marker) | return v:true | endif + else + if filereadable(a:path.'/'.l:marker) | return v:true | endif + endif + endfor + return v:false +endfunction + +" Returns a list of the parents of the current file up to a root directory. +function! s:ParentDirectories(file_path) abort + let l:dirs = [] + let l:current_dir = fnamemodify(a:file_path, ':h') + let l:parent = fnamemodify(l:current_dir, ':h') + while l:parent != l:current_dir + call add(l:dirs, l:current_dir) + let l:current_dir = l:parent + let l:parent = fnamemodify(l:parent, ':h') + endwhile + call add(l:dirs, l:current_dir) + return l:dirs +endfunction diff --git a/plugin/lsc.vim b/plugin/lsc.vim index bab4058d..95f888f3 100644 --- a/plugin/lsc.vim +++ b/plugin/lsc.vim @@ -71,7 +71,7 @@ function! RegisterLanguageServer(filetype, config) abort call lsc#file#track(l:server, l:buffer, a:filetype) endfor else - call lsc#server#start(l:server) + call lsc#server#start(l:server, lsc#file#normalize(l:buffers[0].name)) endif endfunction diff --git a/test/integration/lib/stub_lsp.dart b/test/integration/lib/stub_lsp.dart index 9f74dd14..1ff5aff9 100644 --- a/test/integration/lib/stub_lsp.dart +++ b/test/integration/lib/stub_lsp.dart @@ -4,10 +4,13 @@ import 'package:json_rpc_2/json_rpc_2.dart'; class StubServer { final Peer peer; + Future> get initialization => _initialization.future; + final _initialization = Completer>(); StubServer(this.peer, {Map capabilities = const {}}) { peer - ..registerMethod('initialize', (_) { + ..registerMethod('initialize', (Parameters p) { + _initialization.complete(p.asMap.cast()); return {'capabilities': capabilities}; }) ..registerMethod('initialized', (_) { diff --git a/test/integration/lib/vim_remote.dart b/test/integration/lib/vim_remote.dart index d7a1437b..642b2a8f 100644 --- a/test/integration/lib/vim_remote.dart +++ b/test/integration/lib/vim_remote.dart @@ -46,6 +46,11 @@ class Vim { final result = await Process.run( 'vim', [..._serverNameArg, '--remote-expr', expression]); final stdout = result.stdout as String; + final stderr = result.stderr as String; + if (stderr.isNotEmpty) { + throw Exception( + 'Failed to evaluate vim expression [$expression]:\n$stderr'); + } return stdout.endsWith('\n') ? stdout.substring(0, stdout.length - 1) : stdout; diff --git a/test/integration/test/workspace_folders_test.dart b/test/integration/test/workspace_folders_test.dart new file mode 100644 index 00000000..6bae68a5 --- /dev/null +++ b/test/integration/test/workspace_folders_test.dart @@ -0,0 +1,239 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:async/async.dart'; +import 'package:_test/stub_lsp.dart'; +import 'package:_test/test_bed.dart'; +import 'package:json_rpc_2/json_rpc_2.dart'; +import 'package:test/test.dart'; +import 'package:test_descriptor/test_descriptor.dart' as d; + +void main() { + group('WorkspaceRoot configured', () { + TestBed testBed; + Peer client; + + setUpAll(() async { + await d.dir('workspaces', [ + d.dir('foo', [ + d.file('makefile'), + d.dir('lib', [d.file('foo.txt')]) + ]), + d.dir('bar', [ + d.file('makefile'), + d.dir('lib', [d.file('bar.txt')]) + ]) + ]).create(); + testBed = await TestBed.setup( + config: '"WorkspaceRoot": lsc#workspace#byMarker(["lib/"]),', + ); + }); + + setUp(() async { + final nextClient = testBed.clients.first; + await testBed.vim.edit('workspaces/foo/lib/foo.txt'); + await testBed.vim.sendKeys(':LSClientEnable'); + client = await nextClient; + }); + + tearDown(() async { + await testBed.vim.sendKeys(':LSClientDisable'); + await testBed.vim.sendKeys(':%bwipeout!'); + await client.done; + client = null; + }); + + test('uses root for initialization', () async { + final server = StubServer(client); + + await server.initialized; + final initialization = await server.initialization; + expect(initialization['capabilities']['workspace']['workspaceFolders'], + true); + expect(initialization['workspaceFolders'], [ + { + 'uri': d.dir('workspaces/foo').io.uri.toString(), + 'name': 'workspaces/foo/' + } + ]); + }); + + test('does not send notificaiton without capability', () async { + final server = StubServer(client, capabilities: { + 'workspace': { + 'workspaceFolders': { + 'supported': true, + 'changeNotifications': false, + } + } + }); + + server.peer.registerMethod('workspace/didChangeWorkspaceFolders', + (Parameters p) { + fail('Unexpected call to didChangeWorkspaceFolders'); + }); + await server.initialized; + + await testBed.vim.edit('workspaces/bar/lib/bar.txt'); + + await Future.delayed(const Duration(milliseconds: 10)); + }); + + test('sends notifications with string capability', () async { + final server = StubServer(client, capabilities: { + 'workspace': { + 'workspaceFolders': { + 'supported': true, + 'changeNotifications': 'something', + } + } + }); + final changeController = StreamController>(); + final changeEvents = StreamQueue(changeController.stream); + server.peer.registerMethod('workspace/didChangeWorkspaceFolders', + (Parameters p) { + changeController.add(p['event'].asMap.cast()); + }); + + await server.initialized; + + await testBed.vim.edit('workspaces/bar/lib/bar.txt'); + + final change = await changeEvents.next; + expect(change['removed'], isEmpty); + expect(change['added'], [ + { + 'uri': d.dir('workspaces/bar').io.uri.toString(), + 'name': 'workspaces/bar/' + } + ]); + }); + + test('sends notifications with bool capability', () async { + final server = StubServer(client, capabilities: { + 'workspace': { + 'workspaceFolders': { + 'supported': true, + 'changeNotifications': true, + } + } + }); + + final changeController = StreamController>(); + final changeEvents = StreamQueue(changeController.stream); + server.peer.registerMethod('workspace/didChangeWorkspaceFolders', + (Parameters p) { + changeController.add(p['event'].asMap.cast()); + }); + + await server.initialized; + + await testBed.vim.edit('workspaces/bar/lib/bar.txt'); + + final change = await changeEvents.next; + expect(change['removed'], isEmpty); + expect(change['added'], [ + {'uri': d.dir('workspaces/bar').io.uri.toString(), 'name': anything} + ]); + }); + }); + + group('WorkspaceRoot throws', () { + TestBed testBed; + Peer client; + + setUpAll(() async { + testBed = await TestBed.setup( + beforeRegister: (vim) async { + await vim.sendKeys(':function! ThrowingRoot(path) abort'); + await vim.sendKeys('throw "sad"'); + await vim.sendKeys('endfunction'); + await vim.sendKeys(''); + }, + config: '"WorkspaceRoot":function("ThrowingRoot"),'); + await d.dir('workspaces', [ + d.dir('foo', [ + d.file('makefile'), + d.dir('lib', [d.file('foo.txt')]) + ]), + d.dir('bar', [ + d.file('makefile'), + d.dir('lib', [d.file('bar.txt')]) + ]) + ]).create(); + }); + + setUp(() async { + final nextClient = testBed.clients.first; + await testBed.vim.edit('foo.txt'); + await testBed.vim.sendKeys(':LSClientEnable'); + client = await nextClient; + }); + + tearDown(() async { + await testBed.vim.sendKeys(':LSClientDisable'); + await testBed.vim.sendKeys(':%bwipeout!'); + final file = File('foo.txt'); + if (await file.exists()) await file.delete(); + await client.done; + client = null; + }); + + test('does not advertise capability', () async { + final server = StubServer(client); + + await server.initialized; + final initialization = await server.initialization; + expect(initialization['capabilities']['workspace']['workspaceFolders'], + false); + final messages = await testBed.vim.messages(1); + expect(messages, [ + '[lsc:Error] Disabling workspace roots due to error: \'sad\'', + ]); + }); + }); + + group('No WorkspaceRoot configured', () { + TestBed testBed; + Peer client; + + setUpAll(() async { + testBed = await TestBed.setup(); + await d.dir('workspaces', [ + d.dir('foo', [ + d.file('makefile'), + d.dir('lib', [d.file('foo.txt')]) + ]), + d.dir('bar', [ + d.file('makefile'), + d.dir('lib', [d.file('bar.txt')]) + ]) + ]).create(); + }); + + setUp(() async { + final nextClient = testBed.clients.first; + await testBed.vim.edit('foo.txt'); + await testBed.vim.sendKeys(':LSClientEnable'); + client = await nextClient; + }); + + tearDown(() async { + await testBed.vim.sendKeys(':LSClientDisable'); + await testBed.vim.sendKeys(':%bwipeout!'); + final file = File('foo.txt'); + if (await file.exists()) await file.delete(); + await client.done; + client = null; + }); + + test('does not advertise capability', () async { + final server = StubServer(client); + + await server.initialized; + final initialization = await server.initialization; + expect(initialization['capabilities']['workspace']['workspaceFolders'], + false); + }); + }); +}