In Hands-On LSP Tutorial: Building a Custom Auto-Complete, we built a language server to provide our text editors with GitHub Issue and PR completion.
Final code from that post
import fetch from "node-fetch";
import {
createConnection,
InitializeResult,
TextDocumentSyncKind,
TextDocuments,
} from "vscode-languageserver/node";
import { TextDocument } from "vscode-languageserver-textdocument";
const connection = createConnection();
const documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument);
type Edge = {
node: {
title: string;
number: number;
url: string;
body: string;
};
};
type Response = {
data: { search: { edges: Edge[] } };
};
// Replace with your real GitHub access token
const accessToken = "YOUR_GITHUB_ACCESS_TOKEN";
const searchScope = "repo:facebook/docusaurus";
const search = async (input: string): Promise<Response> => {
const query = `
{
search(query: "${searchScope} ${input}", type: ISSUE, first: 10) {
edges {
node {
... on PullRequest {
title
number
body
url
}
... on Issue {
title
number
body
url
}
}
}
}
}
`;
const response = await fetch("https://api.github.com/graphql", {
method: "POST",
body: JSON.stringify({ query }),
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
const error = {
status: response.status,
statusText: response.statusText,
body: await response.json(),
};
connection.console.error(
`Error fetching from GitHub: ${JSON.stringify(error)}`,
);
}
return await response.json();
};
connection.onInitialize(() => {
const result: InitializeResult = {
capabilities: {
textDocumentSync: TextDocumentSyncKind.Incremental,
// Tell the client that the server supports code completion
completionProvider: {
// We want to listen for the "#" character to trigger our completions
triggerCharacters: ["#"],
},
},
};
return result;
});
connection.onCompletion(async (params) => {
const doc = documents.get(params.textDocument.uri);
if (!doc) {
return null;
}
const line = doc.getText().split("\n")[params.position.line];
// return early if we don't see our trigger character
if (!line.includes("#")) {
return null;
}
const input = line.slice(line.lastIndexOf("#") + 1);
const json = await search(input);
const items = json.data.search.edges.map((edge) => {
return {
label: `#${edge.node.number}: ${edge.node.title}`,
detail: `${edge.node.title}\n\n${edge.node.body}`,
textEdit: {
range: {
start: {
line: params.position.line,
character: line.lastIndexOf("#"),
},
end: {
line: params.position.line,
character: params.position.character,
},
},
newText: `${edge.node.url}`,
},
};
});
return {
isIncomplete: true,
items,
};
});
// Make the text document manager listen on the connection
// for open, change and close text document events
documents.listen(connection);
// Listen on the connection
connection.listen();
It works, but searchScope
(e.g., repo:facebook/docusaurus
) and our GitHub access token are hard-coded. That's fine for local development, but to get this language server ready to share, we should make those configurable.
Approach
- We'll update
server.ts
to use editor-provided config instead of hard-coded values. - We'll update our editor setup to provide config to the server.
Updating our server to use config
First, we remove previous assignments of accessToken
and searchScope
.
- // Replace with your real GitHub access token
- const accessToken = "YOUR_GITHUB_ACCESS_TOKEN";
-
- const searchScope = "repo:facebook/docusaurus";
Instead, we specify the shape of our configuration, a default number of max results, and default our config
to an empty object.
interface Config {
githubAccessToken?: string;
searchScope?: string;
maxResults?: number;
}
const DEFAULT_MAX_RESULTS = 10;
let config: Config = {};
Next, we change our search query to the searchScope
and maxResults
from our settings.
- const search = async (input: string): Promise<Response> => {
- const query = `
- {
- search(query: "${searchScope} ${input}", type: ISSUE, first: 10) {
const search = async (input: string): Promise<Response> => {
const query = `
{
search(query: "${config.searchScope} ${input}", type: ISSUE, first: ${
config.maxResults ?? DEFAULT_MAX_RESULTS
}) {
We use the token from our config.
- Authorization: `Bearer ${accessToken}`,
Authorization: `Bearer ${config.githubAccessToken}`,
We'll add a getConfig
function to get the current config from the editor. We look up our configuration by a prefix we specify (here, myLSP
).
const getConfig = async () => {
config = await connection.workspace.getConfiguration("myLSP");
};
Finally, we call getConfig
in our onCompletion
function and we return early with an error if we're missing any required fields.
connection.onCompletion(async (params) => {
await getConfig();
if (!config.githubAccessToken || !config.searchScope) {
connection.console.error("Both githubAccessToken and searchScope required");
return;
}
// ...
That's all we needed to change for our server.
Updated full server.ts code
import fetch from "node-fetch";
import {
createConnection,
InitializeResult,
TextDocumentSyncKind,
TextDocuments,
} from "vscode-languageserver/node";
import { TextDocument } from "vscode-languageserver-textdocument";
const connection = createConnection();
const documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument);
type Edge = {
node: {
title: string;
number: number;
url: string;
body: string;
};
};
type Response = {
data: { search: { edges: Edge[] } };
};
interface Config {
githubAccessToken?: string;
searchScope?: string;
maxResults?: number;
}
const DEFAULT_MAX_RESULTS = 10;
let config: Config = {};
const search = async (input: string): Promise<Response> => {
const query = `
{
search(query: "${config.searchScope} ${input}", type: ISSUE, first: ${
config.maxResults ?? DEFAULT_MAX_RESULTS
}) {
edges {
node {
... on PullRequest {
title
number
body
url
}
... on Issue {
title
number
body
url
}
}
}
}
}
`;
const response = await fetch("https://api.github.com/graphql", {
method: "POST",
body: JSON.stringify({ query }),
headers: {
Authorization: `Bearer ${config.githubAccessToken}`,
},
});
if (!response.ok) {
const error = {
status: response.status,
statusText: response.statusText,
body: await response.json(),
};
connection.console.error(
`Error fetching from GitHub: ${JSON.stringify(error)}`,
);
}
return await response.json();
};
const getConfig = async () => {
config = await connection.workspace.getConfiguration("myLSP");
};
connection.onInitialize(() => {
const result: InitializeResult = {
capabilities: {
textDocumentSync: TextDocumentSyncKind.Incremental,
// Tell the client that the server supports code completion
completionProvider: {
// We want to listen for the "#" character to trigger our completions
triggerCharacters: ["#"],
},
},
};
return result;
});
connection.onCompletion(async (params) => {
await getConfig();
if (!config.githubAccessToken || !config.searchScope) {
connection.console.error("Both githubAccessToken and searchScope required");
return;
}
const doc = documents.get(params.textDocument.uri);
if (!doc) {
return null;
}
const line = doc.getText().split("\n")[params.position.line];
// return early if we don't see our trigger character
if (!line.includes("#")) {
return null;
}
const input = line.slice(line.lastIndexOf("#") + 1);
const json = await search(input);
const items = json.data.search.edges.map((edge) => {
return {
label: `#${edge.node.number}: ${edge.node.title}`,
detail: `${edge.node.title}\n\n${edge.node.body}`,
textEdit: {
range: {
start: {
line: params.position.line,
character: line.lastIndexOf("#"),
},
end: {
line: params.position.line,
character: params.position.character,
},
},
newText: `${edge.node.url}`,
},
};
});
return {
isIncomplete: true,
items,
};
});
// Make the text document manager listen on the connection
// for open, change and close text document events
documents.listen(connection);
// Listen on the connection
connection.listen();
Providing config from the editor
As in the previous post, I will cover VS Code and Neovim here.
How to configure your server with VS Code
VS Code extensions specify configuration via the root package.json. Replace your contributes
block with the following:
"contributes": {
"configuration": {
"title": "My Extension Name",
"properties": {
"myLSP.githubAccessToken": {
"type": "string",
"default": "",
"description": "Your GitHub access token"
},
"myLSP.searchScope": {
"type": "string",
"default": "repo:facebook/docusaurus",
"description": "Search scope for GitHub PRs/Issues"
},
"myLSP.maxResults": {
"type": "number",
"default": 10,
"description": "Max number of completion suggestions"
}
}
}
},
Note that the myLSP
prefix on the properties matches the string we use in our getConfig
function.
Once you've repackaged and reinstalled your extension, you can modify the settings by pressing ctrl+shift+p (cmd+shift+p on Mac) and typing "Open Settings UI"
Once in the Settings UI, search for myLSP
to modify your settings.
Changes take effect immediately because we pull the settings in our server as needed.
How to configure your server with Neovim
For Neovim, we modify our vim.lsp.start
invocation to add a settings
table to our configuration.
vim.lsp.start {
name = "test-ls",
--- ...
settings = {
myLSP = {
githubAccessToken = os.getenv("GITHUB_ACCESS_TOKEN"),
searchScope = "owner:github",
maxResults = 15,
}
},
}
Since we only provide these settings in our vim.lsp.start
, you'll need to restart your editor for changes to take effect.
What's next?
Next, we will look at distributing our language server to make it trivial for our coworkers and friends to install and use in their editors.
If you'd like to be notified when I publish more LSP content, sign up for my newsletter.