1 module served.utils.serverconfig;
2 
3 /// UDA event called when configuration for any workspace or the unnamed
4 /// workspace got changed.
5 ///
6 /// Expected method signature:
7 /// ```d
8 /// @onConfigChanged
9 /// void changedConfig(ConfigWorkspace target, string[] paths, T config)
10 /// ```
11 /// where `T` is the template argument to `mixin ConfigHandler!T`.
12 enum onConfigChanged;
13 
14 /// UDA event called when all workspaces are processed in configuration
15 /// changes.
16 ///
17 /// Expected method signature:
18 /// ```d
19 /// @onConfigFinished
20 /// void configFinished(size_t count)
21 /// ```
22 enum onConfigFinished;
23 
24 ///
25 struct ConfigWorkspace
26 {
27 	/// Workspace URI, resolved to a local workspace URI, or null if none found.
28 	/// May be null for invalid workspaces or if this is the unnamed workspace.
29 	/// Check `isUnnamedWorkspace` to see if this is the unnamed workspace.
30 	string uri;
31 	/// Only true _iff_ this config applies to the unnamed workspace (folder-less workspace)
32 	bool isUnnamedWorkspace;
33 	/// 0-based index which workspace is being processed out of the total count. (for progress reporting)
34 	size_t index;
35 	/// Number of workspaces which are being processed right now in total. (for progress reporting)
36 	size_t numWorkspaces;
37 
38 	static ConfigWorkspace exactlyOne(string uri)
39 	{
40 		return ConfigWorkspace(uri, false, 0, 1);
41 	}
42 
43 	static ConfigWorkspace unnamedWorkspace()
44 	{
45 		return ConfigWorkspace(null, true, 0, 1);
46 	}
47 
48 	string toString() const @safe {
49 		import std.conv : text;
50 
51 		return isUnnamedWorkspace
52 			? "(unnamed workspace)"
53 			: uri;
54 	}
55 }
56 
57 mixin template ConfigHandler(TConfig)
58 {
59 	import served.lsp.protocol;
60 	import served.lsp.jsonops;
61 	import served.utils.events;
62 
63 	private struct TConfigHolder
64 	{
65 		import std.array;
66 
67 		TConfig config;
68 		alias config this;
69 
70 		private static void compare(string prefix, T)(ref Appender!(string[]) changed, ref T a, ref T b)
71 		{
72 			foreach (i, ref lhs; a.tupleof)
73 			{
74 				alias SubT = typeof(a.tupleof[i]);
75 				// if the value is a simple struct, which is assumed to be user-defined, go through it
76 				static if (is(SubT == struct)
77 					&& __traits(getAliasThis, SubT).length == 0
78 					&& !isVariant!SubT)
79 				{
80 					compare!(prefix ~ __traits(identifier, a.tupleof[i]) ~ ".")(changed,
81 						a.tupleof[i], b.tupleof[i]);
82 				}
83 				else
84 				{
85 					if (a.tupleof[i] != b.tupleof[i])
86 						changed ~= (prefix ~ __traits(identifier, a.tupleof[i]));
87 				}
88 			}
89 		}
90 
91 		string[] replace(TConfig newConfig)
92 		{
93 			string[] ret;
94 			static foreach (i; 0 .. TConfig.tupleof.length)
95 				ret ~= replaceSection!i(newConfig.tupleof[i]);
96 			return ret;
97 		}
98 
99 		string[] replaceSection(size_t tupleOfIdx)(typeof(TConfig.tupleof[tupleOfIdx]) newValue)
100 		{
101 			auto ret = appender!(string[]);
102 			compare!(__traits(identifier, TConfig.tupleof[tupleOfIdx]) ~ ".")(
103 				ret, config.tupleof[tupleOfIdx], newValue);
104 			config.tupleof[tupleOfIdx] = newValue;
105 			return ret.data;
106 		}
107 
108 		string[] replaceAllSectionsJson(string[] settingJsons)
109 		{
110 			assert(settingJsons.length >= TConfig.tupleof.length);
111 			auto changed = appender!(string[]);
112 			static foreach (i; 0 .. TConfig.tupleof.length)
113 			{{
114 				auto json = settingJsons[i];
115 				if (json == `null` || json.isEmptyJsonObject)
116 					changed ~= this.replaceSection!i(typeof(TConfig.tupleof[i]).init);
117 				else
118 					changed ~= this.replaceSection!i(json.deserializeJson!(typeof(TConfig.tupleof[i])));
119 			}}
120 			return changed.data;
121 		}
122 	}
123 
124 	TConfigHolder[DocumentUri] perWorkspaceConfigurationStore;
125 	TConfigHolder* globalConfiguration;
126 
127 	__gshared bool syncedConfiguration = false;
128 	__gshared bool syncingConfiguration = false;
129 
130 	private __gshared bool _hasConfigurationCapability = false;
131 	private __gshared TConfig* initializeConfig = null;
132 
133 	private __gshared bool nonStandardConfiguration = false;
134 
135 	@onInitialize
136 	void postInit_setupConfig(InitializeParams params)
137 	{
138 		auto workspaces = params.getWorkspaceFolders;
139 		foreach (workspace; workspaces)
140 			perWorkspaceConfigurationStore[workspace.uri] = TConfigHolder.init;
141 
142 		if (workspaces.length)
143 			globalConfiguration = workspaces[0].uri in perWorkspaceConfigurationStore;
144 		else
145 			globalConfiguration = new TConfigHolder();
146 
147 		_hasConfigurationCapability = capabilities
148 			.workspace.orDefault
149 			.configuration.orDefault;
150 
151 		if (!params.initializationOptions.isNone) {
152 			// we might have the following options
153 			// - nonStandardConfiguration - `bool`
154 			// - startupConfiguration - a Configuration object
155 			//
156 			// this lets us initialize with a configuration right away, without
157 			// waiting for a client - or an editor extension.
158 			//
159 			// Editor extensions can use `nonStandardConfiguration` to
160 			// circumvent limitations in the LSP frameworks they have to work
161 			// with.
162 			auto options = params.initializationOptions.deref.get!(StringMap!JsonValue);
163 
164 			const nsc = "nonStandardConfiguration" in options;
165 			if (nsc) {
166 				nonStandardConfiguration = nsc.get!bool;
167 			}
168 
169 			const settings = "startupConfiguration" in options;
170 			if (settings) {
171 				initializeConfig = new TConfig();
172 				*initializeConfig = jsonValueTo!TConfig(*settings);
173 			}
174 		}
175 	}
176 
177 	@protocolNotification("initialized")
178 	void setupConfig_Initialized(InitializedParams params)
179 	{
180 		import served.utils.async : setTimeout;
181 
182 		if (initializeConfig)
183 		{
184 			processConfigChange(*initializeConfig);
185 			initializeConfig = null;
186 		}
187 		else
188 		{
189 			// add 250ms timeout after `initialized` notification to give clients
190 			// the chance to send `workspace/didChangeConfiguration` proactively
191 			// before requesting all configs ourselves.
192 			enum waitTimeMs = 250;
193 			setTimeout({
194 				setupConfig_loadAfterTimeout();
195 			}, waitTimeMs);
196 		}
197 	}
198 
199 	private void setupConfig_loadAfterTimeout()
200 	{
201 		if (!syncedConfiguration && !syncingConfiguration)
202 		{
203 			syncedConfiguration = true;
204 			if (_hasConfigurationCapability)
205 			{
206 				if (!syncConfiguration(null, 0, perWorkspaceConfigurationStore.length + 1))
207 					error("Syncing user configuration failed!");
208 
209 				warning(
210 					"Didn't receive any configuration notification, manually requesting all configurations now");
211 
212 				int i;
213 				foreach (uri, cfg; perWorkspaceConfigurationStore)
214 					syncConfiguration(uri, ++i, perWorkspaceConfigurationStore.length + 1);
215 
216 				emitExtensionEvent!onConfigFinished(perWorkspaceConfigurationStore.length);
217 			}
218 			else
219 			{
220 				warning("This Language Client doesn't support configuration requests and also didn't send any "
221 					~ "configuration to serve-d. Initializing using default configuration");
222 
223 				assert(globalConfiguration, __FUNCTION__ ~ " called while globalConfiguration wasn't initialized");
224 
225 				emitExtensionEvent!onConfigChanged(ConfigWorkspace.unnamedWorkspace, null, globalConfiguration.config);
226 			}
227 		}
228 	}
229 
230 	@protocolNotification("workspace/didChangeConfiguration")
231 	void didChangeConfiguration(RootJsonToken params)
232 	{
233 		import std.exception;
234 		if (nonStandardConfiguration) { // client prefers non-standard API
235 			return;
236 		}
237 		enforce(params.json.looksLikeJsonObject, "invalid non-object parameter to didChangeConfiguration");
238 		auto settings = params.json.parseKeySlices!"settings".settings;
239 		enforce(settings.length, `didChangeConfiguration must contain a "settings" key`);
240 
241 		processConfigChange(settings.deserializeJson!TConfig);
242 	}
243 
244 	@protocolNotification("served/didChangeConfiguration")
245 	void didChangeConfigurationNonStd(RootJsonToken params)
246 	{
247 		import std.exception;
248 		info("switching to nonstandard configuration mechanism");
249 		nonStandardConfiguration = true; // client prefers non-standard API
250 		enforce(params.json.looksLikeJsonObject, "invalid non-object parameter to served/didChangeConfiguration");
251 		auto settings = params.json.parseKeySlices!"settings".settings;
252 		enforce(settings.length, `served/didChangeConfiguration must contain a "settings key"`);
253 
254 		processConfigChange(settings.deserializeJson!TConfig);
255 	}
256 
257 	private void processConfigChange(TConfig configuration, bool allowConfigurationRequest = true)
258 	{
259 		syncingConfiguration = true;
260 		scope (exit)
261 		{
262 			syncingConfiguration = false;
263 			syncedConfiguration = true;
264 		}
265 
266 		if (_hasConfigurationCapability
267 			&& allowConfigurationRequest
268 			&& perWorkspaceConfigurationStore.length >= 2)
269 		{
270 			ConfigurationItem[] items;
271 			items = getGlobalConfigurationItems(); // default workspace
272 			const stride = TConfig.tupleof.length;
273 
274 			foreach (uri, cfg; perWorkspaceConfigurationStore)
275 				items ~= getConfigurationItems(uri);
276 
277 			trace("Re-requesting configuration from client because there is more than 1 workspace");
278 			auto res = rpc.sendRequest("workspace/configuration", ConfigurationParams(items));
279 
280 			const expected = perWorkspaceConfigurationStore.length + 1;
281 			string[] settings = validateConfigurationItemsResponse(res, expected);
282 			if (!settings.length)
283 			{
284 				trace("Config request failed, so falling back to global config...");
285 				return processConfigChange(configuration, false);
286 			}
287 
288 			assert(globalConfiguration, __FUNCTION__ ~ " called while globalConfiguration wasn't initialized");
289 
290 			for (size_t i = 0; i < expected; i++)
291 			{
292 				const isDefault = i == 0;
293 				auto workspace = isDefault
294 					? globalConfiguration
295 					: items[i * stride].scopeUri.deref in perWorkspaceConfigurationStore;
296 
297 				if (!workspace)
298 				{
299 					error("Could not find workspace URI response ",
300 						items[i * stride].scopeUri.deref,
301 						" in requested configurations?");
302 					continue;
303 				}
304 
305 				string[] changed = workspace.replaceAllSectionsJson(settings[i * stride .. $]);
306 				emitExtensionEvent!onConfigChanged(
307 					ConfigWorkspace(
308 						isDefault ? null : items[i * stride].scopeUri.deref,
309 						isDefault,
310 						i,
311 						expected
312 					), changed, workspace.config);
313 			}
314 		}
315 		else if (perWorkspaceConfigurationStore.length)
316 		{
317 			auto kv = perWorkspaceConfigurationStore.byKeyValue.front;
318 			if (perWorkspaceConfigurationStore.length > 1)
319 				error("Client does not support configuration request, only applying config for workspace ", kv.key);
320 			auto changed = kv.value.replace(configuration);
321 			emitExtensionEvent!onConfigChanged(
322 				ConfigWorkspace.exactlyOne(kv.key), changed, kv.value.config);
323 		}
324 		else
325 		{
326 			info("initializing config for global fallback workspace");
327 			assert(globalConfiguration, __FUNCTION__ ~ " called while globalConfiguration wasn't initialized");
328 
329 			auto changed = globalConfiguration.replace(configuration);
330 			emitExtensionEvent!onConfigChanged(
331 				ConfigWorkspace.unnamedWorkspace, changed, globalConfiguration.config);
332 		}
333 
334 		emitExtensionEvent!onConfigFinished(perWorkspaceConfigurationStore.length);
335 	}
336 
337 	bool syncConfiguration(string workspaceUri, size_t index = 0, size_t numConfigs = 0, bool addNew = false)
338 	{
339 		if (_hasConfigurationCapability)
340 		{
341 			if (addNew)
342 				perWorkspaceConfigurationStore[workspaceUri] = TConfigHolder.init;
343 
344 			auto proj = workspaceUri in perWorkspaceConfigurationStore;
345 			if (!proj && workspaceUri.length)
346 			{
347 				error("Did not find workspace ", workspaceUri, " when syncing config?");
348 				return false;
349 			}
350 			else if (!proj)
351 			{
352 				assert(globalConfiguration, __FUNCTION__ ~ " called while globalConfiguration wasn't initialized");
353 				proj = globalConfiguration;
354 			}
355 
356 			ConfigurationItem[] items;
357 			if (workspaceUri.length)
358 				items = getConfigurationItems(workspaceUri);
359 			else
360 				items = getGlobalConfigurationItems();
361 
362 			trace("Sending workspace/configuration request for ", workspaceUri);
363 			auto res = rpc.sendRequest("workspace/configuration", ConfigurationParams(items));
364 
365 			string[] settings = validateConfigurationItemsResponse(res);
366 			if (!settings.length)
367 				return false;
368 
369 			string[] changed = proj.replaceAllSectionsJson(settings);
370 			emitExtensionEvent!onConfigChanged(
371 				ConfigWorkspace(workspaceUri, workspaceUri.length == 0, index, numConfigs),
372 				changed, proj.config);
373 			return true;
374 		}
375 		else
376 			return false;
377 	}
378 
379 	private ConfigurationItem[] getGlobalConfigurationItems()
380 	{
381 		ConfigurationItem[] items = new ConfigurationItem[TConfig.tupleof.length];
382 		foreach (i, section; TConfig.init.tupleof)
383 			items[i] = ConfigurationItem(Optional!string.init, opt(TConfig.tupleof[i].stringof));
384 		return items;
385 	}
386 
387 	private ConfigurationItem[] getConfigurationItems(DocumentUri uri)
388 	{
389 		ConfigurationItem[] items = new ConfigurationItem[TConfig.tupleof.length];
390 		foreach (i, section; TConfig.init.tupleof)
391 			items[i] = ConfigurationItem(opt(uri), opt(TConfig.tupleof[i].stringof));
392 		return items;
393 	}
394 
395 	private string[] validateConfigurationItemsResponse(scope return ref ResponseMessageRaw res,
396 			size_t expected = size_t.max)
397 	{
398 		if (!res.resultJson.looksLikeJsonArray)
399 		{
400 			error("Got invalid configuration response from language client. (not an array)");
401 			trace("Response: ", res);
402 			return null;
403 		}
404 
405 		string[] settings;
406 		int i;
407 		res.resultJson.visitJsonArray!(v => i++);
408 		settings.length = i;
409 		i = 0;
410 		res.resultJson.visitJsonArray!(v => settings[i++] = v);
411 
412 		if (settings.length % TConfig.tupleof.length != 0)
413 		{
414 			error("Got invalid configuration response from language client. (invalid length)");
415 			trace("Response: ", res);
416 			return null;
417 		}
418 		if (expected != size_t.max)
419 		{
420 			auto total = settings.length / TConfig.tupleof.length;
421 			if (total > expected)
422 			{
423 				warning("Loading different amount of workspaces than requested: requested ",
424 						expected, " but loading ", total);
425 			}
426 			else if (total < expected)
427 			{
428 				error("Didn't get all configs we asked for: requested ", expected, " but loading ", total);
429 				return null;
430 			}
431 		}
432 		return settings;
433 	}
434 }