WorkspaceConfiguration middleware

One of the things I’ve struggled with when setting up a LanguageClient is correctly mapping Nova’s configuration to what a LanguageServer expects.

For example nova-yaml has a yaml.format.enable configuration and Nova sends { 'format.enable': true } back from workspace/configuration requests. But, the LanguageServer is expecting { format: { enable: true } }. There are quite a few other keys I’m having trouble with but I won’t detail that here.

I’ve been looking through the LSP spec for workspace/configuration, but it doesn’t offer any information on how configuration and sections should be structured, parsed or processed. LanguageServers seem to implement it the way VSCode does, which is to expand dot-notation after the section part into nested JSON.

What the LSP has to say about sections:

/**
 * The configuration section asked for.
 */
section?: string;

In a related LSP issue the maintainer offers up VSCode’s implementation as a reference and says client-side middleware is the best way to support lots of different servers.

I suspect the maintainer meant to link here: https://github.com/microsoft/vscode-languageserver-node/blob/main/client/src/common/configuration.ts and the repo has since been reorganised.

LSP background

From the LSP spec, the LanguageServer is recommended to pull configuration from the LanguageClient through workspace/configuration requests. As an example, the YAML server I’ve been working on requests this:

{
  "items": [
    { "section": "yaml" },
    { "section": "http.proxy" },
    { "section": "http.proxyStrictSSL" },
    { "section": "[yaml]" }
  ]
}

and the LanguageClient is responsible for returning configuration in the same order for each of those keys:

[
  {
    "format.enable": true,
    "validate": true,
    "customTags": ["!secret scalar"],
    "completion": true,
    "schemas": {},
    "hover": true
  },
  {},
  {},
  {}
]

So if every LanguageServer is expecting different things, I think it would make sense for Nova to allow LanguageClient implementors to provide their own custom logic to process the configuration passed to the server. Below I will sketch out how this could work.

Design options

There are three ways that I think this could be implemented:

Option 1: A single middleware

A single call for the whole of the workspace/configuration result:

// Directly from
// https://microsoft.github.io/language-server-protocol/specifications/specification-3-17/#configurationParams
interface ConfigurationParams {}

client = new LanguageClient("...");

client.onConfigurationRequest(async (params: ConfigurationParams) => {
  return params.items.map((item) => {
    if (item.section === "yaml") {
      const format = {
        enable:
          nova.workspace.config.get("yaml.format.enable") ??
          nova.config.get("yaml.format.enable") ??
          false,
      };
      return { format };
    }
    return null;
  });
});

This would allow complete customisation for handling workspace/configuration and LanguageClient implementors could use the existing Configuration API to respond however they like. I don’t like that this would lose out on Nova’s default implementation, which means more LanguageClient code is needed.

An addition to the Configuration API would be useful to fetch values with a cascade, i.e. trying “workspace” then “extension” or falling back to a value. I presume Nova is doing something like this under the hood already?

Option 2: Per section middleware

A call for each section in workspace/configuration, which are merged back together by Nova into a single result.

interface ConfigurationSectionRequest {
  section?: string; // I've put this as optional as it is in the LSP spec
  scopeUri?: string;
  computedConfiguration?: any;
}

client = new LanguageClient("...");

client.onConfigurationRequest(async (request: ConfigurationSectionRequest) => {
  if (request.section === "yaml") {
    return {
      format: {
        enable: request.computedConfiguration["format.enable"] ?? false,
      },
    };
  }
  return null;
});

My impression is that a call for each section would make it easier and cleaner for LanguageClient implementors. It would save having to organise the configuration sections into an array and a per-call middleware leads itself to a nice case/switch or if/else statement. The computedConfiguration parameter could be what Nova would have passed as that section, to allow it to be reused or modified.

scopeUri

Something could be done with the scopeUri, to map it to an TextDocument in Nova. For instance passing the tabLength from a TextDocument to the configuration. This is probably out-of-scope and could be achieved by filtering workspace.textDocuments.

Option 3: Per section middleware v2

Register a middleware per section explicitly with the section you want to override.

interface ConfigurationSectionRequest {
  section?: string;
  scopeUri?: string;
  computedConfiguration?: any;
}

client = new LanguageClient("...");

client.onConfigurationRequest(
  "yaml",
  async (request: ConfigurationSectionRequest) => {
    return {
      format: {
        enable: request.computedConfiguration["format.enable"] ?? false,
      },
    };
  }
);

This would produce slightly cleaner LanguageClient code, but at the cost of making Nova’s internal implementation more complicated. Any sections that are not overriden could fall back to Nova’s default implementation.

Key points

  • The request.computedConfiguration parameter could be what Nova would have passed as that section, to allow it to be reused or modified.
  • Allowing the middleware to be async could generally be good to support more use-cases, but I don’t have a definite use for this myself. Making it async may introduce some unreliability if developers try to do complicated non-synchronous things here.
  • This should be purely additive so the current logic for configuration would stay where it is, then it could call this middleware if it has been defined. I would recommend the current configuration is documented more, that was something I only worked out by looking through the raw LSP messages and stepping through LanguageServer breakpoints.
  • onConfigurationRequest could return a Disposable to unregister the middleware, that seems to be the idiomatic way.
  • Personally, I would use arrow-functions, but a thisValue could be passed like for other Nova functions too.

I think Option 2 is the best choice, as a balance between the extensibility it provides and brevity of the code to use it. I’d be very interested to know what other LanguageClient implementors think about this, and any thoughts, feelings, feedback and suggestions!

Thanks,
Rob

3 Likes

I don’t have much to add, since I haven’t actually messed with workspace/configuration at all, but I think this is really well researched and thought out.

I’d want to understand more about the logic behind Nova’s responses to the configuration request, as you mentioned, before I made a call, but so far I don’t have any concerns with your recommendation (option 2).

I also wouldn’t have a use for thisValue.

I’m running into this problem specifically. I have a language server that takes a bunch of parameters, but really there isn’t much in the way of explanation about how to provide these options and link them to the server.

Additionally, I might want to have some options which are presented in one way (say a combined option) for usability in the UI, but get expressed to the server another way (as two separate options for example).