Skip to content
This repository was archived by the owner on Nov 6, 2018. It is now read-only.

Commit 65866f5

Browse files
committed
feat: add config cascade and config server listener
This makes it easier to write extensions that use configuration by exposing the current (optimistically synced with the client) configuration in one place.
1 parent 124aba4 commit 65866f5

12 files changed

+352
-26
lines changed

src/protocol/configuration.ts

+5-3
Original file line numberDiff line numberDiff line change
@@ -109,15 +109,17 @@ export interface DidChangeConfigurationParams {
109109
* For example, the client might support configuring settings globally and per-user, and it is designed so that
110110
* user settings override global settings. Then there would be two subjects, one for global settings and one for
111111
* the user.
112+
*
113+
* @template S the settings type
112114
*/
113-
export interface ConfigurationCascade {
115+
export interface ConfigurationCascade<C extends Settings = Settings> {
114116
/** The final settings, merged from all subjects in the cascade. */
115-
merged: Settings
117+
merged: C
116118

117119
/**
118120
* The configuration subjects in the cascade, from lower to higher precedence.
119121
*
120122
* Extensions: The merged settings value usually suffices.
121123
*/
122-
subjects?: { settings: Settings }[]
124+
subjects?: { settings: C }[]
123125
}

src/server/features/client.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Unsubscribable } from 'rxjs'
22
import uuidv4 from 'uuid/v4'
33
import { MessageType as RPCMessageType, NotificationType, RequestType } from '../../jsonrpc2/messages'
44
import {
5-
ClientCapabilities,
5+
InitializeParams,
66
Registration,
77
RegistrationParams,
88
RegistrationRequest,
@@ -211,7 +211,7 @@ export class RemoteClientImpl implements RemoteClient {
211211
return this._connection
212212
}
213213

214-
public initialize(_capabilities: ClientCapabilities): void {
214+
public initialize(_params: InitializeParams): void {
215215
/* noop */
216216
}
217217

src/server/features/common.ts

+6-5
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { ClientCapabilities, ServerCapabilities } from '../../protocol'
1+
import { Unsubscribable } from 'rxjs'
2+
import { InitializeParams, ServerCapabilities } from '../../protocol'
23
import { IConnection } from '../server'
34

45
/**
5-
*
6+
* A proxy for values and methods that exist on the remote client.
67
*/
7-
export interface Remote {
8+
export interface Remote extends Partial<Unsubscribable> {
89
/**
910
* Attach the remote to the given connection.
1011
*
@@ -21,9 +22,9 @@ export interface Remote {
2122
* Called to initialize the remote with the given
2223
* client capabilities
2324
*
24-
* @param capabilities The client capabilities
25+
* @param params the initialization parameters from the client
2526
*/
26-
initialize(capabilities: ClientCapabilities): void
27+
initialize(params: InitializeParams): void
2728

2829
/**
2930
* Called to fill in the server capabilities this feature implements.
+135
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import assert from 'assert'
2+
import { NotificationHandler } from '../../jsonrpc2/handlers'
3+
import {
4+
ConfigurationCascade,
5+
ConfigurationUpdateParams,
6+
ConfigurationUpdateRequest,
7+
DidChangeConfigurationParams,
8+
Settings,
9+
} from '../../protocol'
10+
import { IConnection } from '../server'
11+
import { RemoteConfiguration, setValueAtKeyPath } from './configuration'
12+
13+
const EMPTY_MOCK_CONNECTION: IConnection = {
14+
onDidChangeConfiguration: () => void 0,
15+
sendRequest: () => Promise.resolve(void 0),
16+
} as any
17+
18+
const FIXTURE_CONFIGURATION_CASCADE: ConfigurationCascade<Settings> = { merged: { a: 1 } }
19+
20+
describe('RemoteConfigurationImpl', () => {
21+
const create = (
22+
connection: IConnection = EMPTY_MOCK_CONNECTION,
23+
configurationCascade = FIXTURE_CONFIGURATION_CASCADE
24+
): RemoteConfiguration<Settings> => {
25+
const remote = new RemoteConfiguration<Settings>()
26+
remote.attach(connection)
27+
remote.initialize({
28+
root: null,
29+
capabilities: {},
30+
configurationCascade,
31+
workspaceFolders: null,
32+
})
33+
return remote
34+
}
35+
36+
it('initially reports an empty config', () => {
37+
const remote = new RemoteConfiguration<Settings>()
38+
assert.deepStrictEqual(remote.configuration.value, {} as Settings)
39+
})
40+
41+
it('records the configuration value from initialize', () => {
42+
const remote = create()
43+
assert.deepStrictEqual(remote.configuration.value, { a: 1 } as Settings)
44+
})
45+
46+
it('records the configuration value from client update notifications', () => {
47+
let onDidChangeConfiguration: NotificationHandler<DidChangeConfigurationParams> | undefined
48+
const remote = create({
49+
...EMPTY_MOCK_CONNECTION,
50+
onDidChangeConfiguration: h => (onDidChangeConfiguration = h),
51+
})
52+
assert.ok(onDidChangeConfiguration)
53+
onDidChangeConfiguration!({ configurationCascade: { merged: { b: 2 } } })
54+
assert.deepStrictEqual(remote.configuration.value, { b: 2 } as Settings)
55+
})
56+
57+
it('updateConfiguration edit is sent as request and reflected immediately', async () => {
58+
const remote = create({
59+
...EMPTY_MOCK_CONNECTION,
60+
sendRequest: async (type: any, params: any) => {
61+
assert.strictEqual(type, ConfigurationUpdateRequest.type)
62+
assert.deepStrictEqual(params, { path: ['b'], value: 2 } as ConfigurationUpdateParams)
63+
},
64+
})
65+
const updated = remote.updateConfiguration(['b'], 2)
66+
assert.deepStrictEqual(remote.configuration.value, { a: 1, b: 2 } as Settings)
67+
await updated
68+
assert.deepStrictEqual(remote.configuration.value, { a: 1, b: 2 } as Settings)
69+
})
70+
71+
it('handles interleaved updateConfiguration and didChangeConfiguration (authoritative)', async () => {
72+
let onDidChangeConfiguration: NotificationHandler<DidChangeConfigurationParams> | undefined
73+
const remote = create({
74+
...EMPTY_MOCK_CONNECTION,
75+
sendRequest: async (type: any, params: any) => {
76+
assert.strictEqual(type, ConfigurationUpdateRequest.type)
77+
assert.deepStrictEqual(params, { path: ['b'], value: 2 } as ConfigurationUpdateParams)
78+
},
79+
onDidChangeConfiguration: h => (onDidChangeConfiguration = h),
80+
})
81+
const updated = remote.updateConfiguration(['b'], 2)
82+
assert.deepStrictEqual(remote.configuration.value, { a: 1, b: 2 } as Settings)
83+
onDidChangeConfiguration!({ configurationCascade: { merged: { c: 3 } } })
84+
await updated
85+
assert.deepStrictEqual(remote.configuration.value, { c: 3 } as Settings)
86+
})
87+
})
88+
89+
describe('setValueAtKeyPath', () => {
90+
it('overwrites the top level', () => assert.deepStrictEqual(setValueAtKeyPath({ a: 1 }, [], { b: 2 }), { b: 2 }))
91+
it('overwrites an existing property', () => assert.deepStrictEqual(setValueAtKeyPath({ a: 1 }, ['a'], 2), { a: 2 }))
92+
it('sets a new property', () => assert.deepStrictEqual(setValueAtKeyPath({ a: 1 }, ['b'], 2), { a: 1, b: 2 }))
93+
it('sets a property overwriting an array', () => assert.deepStrictEqual(setValueAtKeyPath([1], ['a'], 2), { a: 2 }))
94+
it('sets a property overwriting a primitive', () =>
95+
assert.deepStrictEqual(setValueAtKeyPath(1 as any, ['a'], 2), { a: 2 }))
96+
it('overwrites an existing nested property', () =>
97+
assert.deepStrictEqual(setValueAtKeyPath({ a: { b: 1 } }, ['a', 'b'], 2), { a: { b: 2 } }))
98+
it('deletes a property', () =>
99+
assert.deepStrictEqual(setValueAtKeyPath({ a: 1, b: 2 }, ['a'], undefined), { b: 2 }))
100+
it('sets a new nested property', () =>
101+
assert.deepStrictEqual(setValueAtKeyPath({ a: { b: 1 } }, ['a', 'c'], 2), { a: { b: 1, c: 2 } }))
102+
it('sets a new deeply nested property', () =>
103+
assert.deepStrictEqual(setValueAtKeyPath({ a: { b: { c: 1 } } }, ['a', 'b', 'd'], 2), {
104+
a: { b: { c: 1, d: 2 } },
105+
}))
106+
it('overwrites an object', () => assert.deepStrictEqual(setValueAtKeyPath({ a: { b: 1 } }, ['a'], 2), { a: 2 }))
107+
it('sets a value that requires a new object', () =>
108+
assert.deepStrictEqual(setValueAtKeyPath({}, ['a', 'b'], 1), { a: { b: 1 } }))
109+
110+
it('overwrites an existing index', () => assert.deepStrictEqual(setValueAtKeyPath([1], [0], 2), [2]))
111+
it('inserts a new index', () => assert.deepStrictEqual(setValueAtKeyPath([1], [1], 2), [1, 2]))
112+
it('inserts a new index at end', () => assert.deepStrictEqual(setValueAtKeyPath([1, 2], [-1], 3), [1, 2, 3]))
113+
it('inserts an index overwriting an object', () => assert.deepStrictEqual(setValueAtKeyPath({ a: 1 }, [0], 2), [2]))
114+
it('inserts an index overwriting a primitive', () =>
115+
assert.deepStrictEqual(setValueAtKeyPath(1 as any, [0], 2), [2]))
116+
it('overwrites an existing nested index', () =>
117+
assert.deepStrictEqual(setValueAtKeyPath([1, [2]], [1, 0], 3), [1, [3]]))
118+
it('deletes an index', () => assert.deepStrictEqual(setValueAtKeyPath([1, 2, 3], [1], undefined), [1, 3]))
119+
it('sets a new nested index', () =>
120+
assert.deepStrictEqual(setValueAtKeyPath([1, [1, 2, [1, 2, 3, 4]]], [1, 2, 3], 5), [1, [1, 2, [1, 2, 3, 5]]]))
121+
it('inserts a new nested index at end', () =>
122+
assert.deepStrictEqual(setValueAtKeyPath([1, [2]], [1, -1], 3), [1, [2, 3]]))
123+
it('overwrites an array', () => assert.deepStrictEqual(setValueAtKeyPath([1, [2]], [1], 3), [1, 3]))
124+
it('sets a value that requires a new array', () => assert.deepStrictEqual(setValueAtKeyPath([], [0, 0], 1), [[1]]))
125+
126+
it('sets a nested property (and does not modify input)', () => {
127+
const input = { a: [{}, { b: [1, 2] }] }
128+
const origInput = JSON.parse(JSON.stringify(input))
129+
assert.deepStrictEqual(setValueAtKeyPath(input, ['a', 1, 'b', -1], { c: 3 }), {
130+
a: [{}, { b: [1, 2, { c: 3 }] }],
131+
})
132+
assert.deepStrictEqual(input, origInput)
133+
})
134+
it('throws on invalid key type', () => assert.throws(() => setValueAtKeyPath({}, [true as any], {})))
135+
})

src/server/features/configuration.ts

+166
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { BehaviorSubject, Observable, Subscription, Unsubscribable } from 'rxjs'
2+
import { distinctUntilChanged, map } from 'rxjs/operators'
3+
import { Emitter, Event } from '../../jsonrpc2/events'
4+
import { InitializeParams, ServerCapabilities } from '../../protocol'
5+
import {
6+
ConfigurationCascade,
7+
ConfigurationUpdateParams,
8+
ConfigurationUpdateRequest,
9+
KeyPath,
10+
Settings,
11+
} from '../../protocol/configuration'
12+
import { isEqual } from '../../util'
13+
import { IConnection } from '../server'
14+
import { Remote } from './common'
15+
16+
/**
17+
* A proxy for the client's configuration.
18+
*
19+
* @template C settings type
20+
*/
21+
export class RemoteConfiguration<C extends Settings> implements Remote, Unsubscribable {
22+
private subscription = new Subscription()
23+
private _connection?: IConnection
24+
private _configurationCascade = new BehaviorSubject<ConfigurationCascade<C>>({ merged: {} as C })
25+
private onChange = new Emitter<void>()
26+
27+
/**
28+
* An observable of the configuration that emits whenever it changes (when the extension itself updates the
29+
* configuration, or when the extension receives a workspace/didChangeConfiguration notification from the
30+
* client).
31+
*
32+
* The current merged configuration is available in the `value` property of the returned object, for callers
33+
* that want to access it directly without subscribing to the observable.
34+
*/
35+
public get configuration(): Observable<Readonly<C>> & { value: Readonly<C> } {
36+
const o: Observable<C> & { value: C } = Object.create(
37+
this._configurationCascade.pipe(
38+
map(({ merged }) => merged),
39+
distinctUntilChanged((a, b) => isEqual(a, b))
40+
)
41+
)
42+
o.value = this._configurationCascade.value.merged
43+
return o
44+
}
45+
46+
/**
47+
* Emits when the configuration changes (when the extension itself updates the configuration, or when the
48+
* extension receives a workspace/didChangeConfiguration notification from the client).
49+
*/
50+
public readonly onDidChangeConfiguration: Event<void> = this.onChange.event
51+
52+
public attach(connection: IConnection): void {
53+
this._connection = connection
54+
55+
// Listen for `workspace/didChangeConfiguration` notifications from the client.
56+
this.subscription.add(
57+
connection.onDidChangeConfiguration(params => {
58+
this._configurationCascade.next(params.configurationCascade as ConfigurationCascade<C>)
59+
this.onChange.fire(void 0)
60+
})
61+
)
62+
}
63+
64+
public get connection(): IConnection {
65+
if (!this._connection) {
66+
throw new Error('Remote is not attached to a connection yet.')
67+
}
68+
return this._connection
69+
}
70+
71+
public initialize(params: InitializeParams): void {
72+
this._configurationCascade.next(params.configurationCascade as ConfigurationCascade<C>)
73+
this.onChange.fire(void 0)
74+
}
75+
76+
public fillServerCapabilities(_capabilities: ServerCapabilities): void {
77+
/* noop */
78+
}
79+
80+
/**
81+
* Updates the configuration setting at the given key path to the given value. The local merged configuration
82+
* is immediately updated to reflect the change (optimistically, even before the server acknowledges the
83+
* update).
84+
*
85+
* Implementation: sends a configuration/update notification to the client.
86+
*
87+
* @param path the key path of the configuration setting to update
88+
* @param value the value to insert
89+
*/
90+
public updateConfiguration(path: KeyPath, value: any): Promise<void> {
91+
// Optimistically apply configuration update locally. If this diverges from the server's state, a
92+
// subsequent didChangeConfiguration notification will inform us.
93+
const cur = this._configurationCascade.value
94+
this._configurationCascade.next({ ...cur, merged: setValueAtKeyPath(cur.merged, path, value) })
95+
this.onChange.fire(void 0)
96+
97+
return this.connection.sendRequest(ConfigurationUpdateRequest.type, {
98+
path,
99+
value,
100+
} as ConfigurationUpdateParams)
101+
}
102+
103+
public unsubscribe(): void {
104+
this.subscription.unsubscribe()
105+
}
106+
}
107+
108+
/**
109+
* Returns the source object with the given value inserted in the location specified by the key path. The source
110+
* object is not modified. The key path indexes into the object successively for each element in the key path.
111+
*
112+
* If the value is `undefined`, the value at the key path is removed.
113+
*
114+
* This must behave identically to {@link module:jsonc-parser.setProperty}.
115+
*/
116+
export function setValueAtKeyPath(source: any, path: KeyPath, value: any): any {
117+
if (path.length === 0) {
118+
// Overwrite entire value.
119+
return value
120+
}
121+
122+
const root = [source]
123+
let prev: any = root // maintain an lvalue that we can assign to
124+
for (const [i, key] of path.entries()) {
125+
const last = i === path.length - 1
126+
const prevKey = i === 0 ? 0 : path[i - 1]
127+
if (typeof key === 'string') {
128+
if (last) {
129+
if (value === undefined) {
130+
prev[prevKey] = { ...prev[prevKey] }
131+
delete prev[prevKey][key]
132+
} else if (
133+
prev[prevKey] !== null &&
134+
typeof prev[prevKey] === 'object' &&
135+
!Array.isArray(prev[prevKey])
136+
) {
137+
prev[prevKey] = { ...prev[prevKey], [key]: value }
138+
} else {
139+
prev[prevKey] = { [key]: value }
140+
}
141+
} else {
142+
prev[prevKey] =
143+
prev[prevKey] !== null && typeof prev[prevKey] === 'object' && !Array.isArray(prev[prevKey])
144+
? { ...prev[prevKey] }
145+
: {}
146+
}
147+
} else if (typeof key === 'number') {
148+
if (last) {
149+
const index = key === -1 ? prev[prevKey].length : key
150+
const head = Array.isArray(prev[prevKey]) ? prev[prevKey].slice(0, index) : []
151+
const tail = Array.isArray(prev[prevKey]) ? prev[prevKey].slice(index + 1) : []
152+
if (value === undefined) {
153+
prev[prevKey] = [...head, ...tail]
154+
} else {
155+
prev[prevKey] = [...head, value, ...tail]
156+
}
157+
} else {
158+
prev[prevKey] = Array.isArray(prev[prevKey]) ? [...prev[prevKey]] : []
159+
}
160+
} else {
161+
throw new Error(`invalid key in key path: ${key} (full key path: ${JSON.stringify(path)}`)
162+
}
163+
prev = prev[prevKey]
164+
}
165+
return root[0]
166+
}

src/server/features/console.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Logger, MessageConnection } from '../../jsonrpc2/connection'
2-
import { ClientCapabilities, LogMessageNotification, MessageType, ServerCapabilities } from '../../protocol'
2+
import { InitializeParams, LogMessageNotification, MessageType, ServerCapabilities } from '../../protocol'
33
import { IConnection } from '../server'
44
import { Remote } from './common'
55

@@ -61,7 +61,7 @@ export class ConnectionLogger implements Logger, RemoteConsole {
6161
/* noop */
6262
}
6363

64-
public initialize(_capabilities: ClientCapabilities): void {
64+
public initialize(_params: InitializeParams): void {
6565
/* noop */
6666
}
6767

0 commit comments

Comments
 (0)