Skip to content

Instantly share code, notes, and snippets.

@laytan
Last active January 18, 2024 03:19
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save laytan/e5a474b5a6864ccbe802f477d7e5a7eb to your computer and use it in GitHub Desktop.
Save laytan/e5a474b5a6864ccbe802f477d7e5a7eb to your computer and use it in GitHub Desktop.
Incomplete LSP example Odin
package lsp
import "core:bufio"
import "core:encoding/json"
import "core:io"
import "core:log"
Null :: distinct struct{}
Initialize_Error :: Response_Error(Initialize_Error_Data)
Initialize :: proc(u: rawptr, msg: Initialize_Params) -> (res: Initialize_Result, err: Maybe(Initialize_Error))
Shutdown_Error :: Response_Error(Null)
Shutdown :: proc(u: rawptr) -> (err: Maybe(Shutdown_Error))
On_Open :: proc(u: rawptr, noti: Did_Open_Params)
On_Change :: proc(u: rawptr, noti: Did_Change_Params)
On_Close :: proc(u: rawptr, noti: Did_Close_Params)
Completion_Error :: Response_Error(Null)
Completion :: proc(u: rawptr, msg: Completion_Params) -> (res: Completion_List, err: Maybe(Completion_Error))
Server :: struct {
data: rawptr,
initialize: Initialize,
on_initialized: Maybe(proc(u: rawptr)),
shutdown: Maybe(Shutdown),
on_exit: Maybe(proc(u: rawptr)),
on_open: Maybe(On_Open),
on_change: Maybe(On_Change),
on_close: Maybe(On_Close),
completion: Maybe(Completion),
// Server state:
initialized: bool,
shutdown_received: bool,
}
Run_Error :: union #shared_nil {
bufio.Scanner_Error,
json.Unmarshal_Error,
json.Marshal_Error,
Run_Error_Opt,
io.Error,
}
Run_Error_Opt :: enum {
Invalid_Headers,
Invalid_JSONRPC_Version,
Exit_Before_Shutdown,
}
run :: proc(s: ^Server, input: io.Reader, output: io.Writer) -> Run_Error {
scanner: bufio.Scanner
bufio.scanner_init(&scanner, input)
defer bufio.scanner_destroy(&scanner)
reqs_loop: for {
defer free_all(context.temp_allocator)
log.debug("scan headers")
content_length, ok := scan_headers(&scanner)
if !ok do return .Invalid_Headers
log.debugf("scan %i bytes", content_length)
context.user_index = content_length
scanner.split = scan_num_bytes
defer scanner.split = bufio.scan_lines
bufio.scanner_scan(&scanner) or_break
content := bufio.scanner_bytes(&scanner)
// TODO: require having gotten initialize as the first.
pm: Partial_Message
json.unmarshal(content, &pm, allocator=context.temp_allocator) or_return
log.debug(pm)
if pm.jsonrpc != "2.0" {
return .Invalid_JSONRPC_Version
}
if !s.initialized {
switch pm.method {
case "initialize", "exit":
// let them through.
case:
log.warn("non-initialization method when not initialized yet")
send(output, not_initialized(pm) or_return)
continue reqs_loop
}
}
data: []byte
switch pm.method {
case "initialize":
s.initialized = true
msg: Request_Message(Initialize_Params)
json.unmarshal(content, &msg, allocator=context.temp_allocator) or_return
response, err := s.initialize(s.data, msg.params)
if err == nil {
res_msg: Response_Result_Message(Initialize_Result)
res_msg.id = msg.id
res_msg.result = response
data = json.marshal(res_msg, allocator=context.temp_allocator) or_return
log.debug("initialize success")
} else {
res_msg: Response_Error_Message(Initialize_Error_Data)
res_msg.id = msg.id
res_msg.error = err.?
data = json.marshal(res_msg, allocator=context.temp_allocator) or_return
log.warn("initialize error")
}
case "initialized":
if oninit, ok := s.on_initialized.?; ok {
oninit(s.data)
}
case "shutdown":
s.shutdown_received = true
if shutit, ok := s.shutdown.?; ok {
err := shutit(s.data)
if err == nil {
res_msg: Response_Result_Message(Null)
res_msg.id = pm.id
data = json.marshal(res_msg, allocator=context.temp_allocator) or_return
log.debug("shutdown success")
} else {
res_msg: Response_Error_Message(Null)
res_msg.id = pm.id
res_msg.error = err.?
data = json.marshal(res_msg, allocator=context.temp_allocator) or_return
log.warn("shutdown error")
}
} else {
data = method_not_found(pm) or_return
}
case "exit":
if onexit, ok := s.on_exit.?; ok {
onexit(s.data)
}
if s.shutdown_received {
log.debug("clean exit")
return nil
} else {
log.warn("exit without a previous shutdown")
return .Exit_Before_Shutdown
}
case "textDocument/didOpen":
if on, nok := s.on_open.?; nok {
noti: Notification_Message(Did_Open_Params)
json.unmarshal(content, &noti, allocator=context.temp_allocator) or_return
on(s.data, noti.params)
} else {
data = method_not_found(pm) or_return
}
case "textDocument/didChange":
if on, nok := s.on_change.?; nok {
noti: Notification_Message(Did_Change_Params)
json.unmarshal(content, &noti, allocator=context.temp_allocator) or_return
on(s.data, noti.params)
} else {
data = method_not_found(pm) or_return
}
case "textDocument/didClose":
if on, nok := s.on_close.?; nok {
noti: Notification_Message(Did_Close_Params)
json.unmarshal(content, &noti, allocator=context.temp_allocator) or_return
on(s.data, noti.params)
} else {
data = method_not_found(pm) or_return
}
case "textDocument/completion":
if complete, cok := s.completion.?; cok {
msg: Request_Message(Completion_Params)
json.unmarshal(content, &msg, allocator=context.temp_allocator) or_return
res, err := complete(s.data, msg.params)
if err == nil {
res_msg: Response_Result_Message(Completion_List)
res_msg.id = pm.id
res_msg.result = res
data = json.marshal(res_msg, allocator=context.temp_allocator) or_return
log.debugf("completion success, with %i results", len(res.items))
} else {
res_msg: Response_Error_Message(Null)
res_msg.id = pm.id
res_msg.error = err.?
data = json.marshal(res_msg, allocator=context.temp_allocator) or_return
log.warn("completion error")
}
} else {
data = method_not_found(pm) or_return
}
}
send(output, data)
}
return bufio.scanner_error(&scanner)
}
package lsp
// TODO: need `omitempty` option for JSON, and unmarshal strings into enum.
// TODO: support `using` fields.
ID :: union{int, string}
Document_URI :: distinct string
URI :: distinct string
Object :: map[string]Any
Array :: []Any
Any :: union {
Object,
Array,
string,
int,
f32,
bool,
}
Versioned_Text_Document_Identifier :: struct {
uri: Document_URI,
version: int,
}
Range :: struct {
start: Position,
end: Position,
}
Position :: struct {
// Zero-based.
line: u32,
// Character offset on a line in a document (zero-based). The meaning of this
// offset is determined by the negotiated `PositionEncodingKind`.
//
// If the character value is greater than the line length it defaults back
// to the line length.
character: u32,
}
Partial_Message :: struct {
id: ID,
jsonrpc: string,
method: string,
}
Request_Message :: struct($T: typeid) {
jsonrpc: string,
method: string,
id: ID,
params: T, // Must marshal to an object or array.
}
Response_Result_Message :: struct($T: typeid) {
id: ID,
result: T,
}
Response_Error_Message :: struct($T: typeid) {
id: ID,
error: Response_Error(T),
}
Notification_Message :: struct($T: typeid) {
jsonrpc: string,
method: string,
params: T,
}
Cancel_Params :: struct {
id: ID,
}
// notification_cancel :: proc(id: ID) -> Notification_Message(Cancel_Params) {
// return {
// method = "$/cancelRequest",
// params = Cancel_Params{ id },
// }
// }
Progress_Token :: distinct ID
Progress_Params :: struct($T: typeid) {
token: Progress_Token,
value: T,
}
// notification_progress :: proc(token: Progress_Token, value: $T) -> Notification_Message(Progress_Params(T)) {
// return {
// method = "$/progress",
// params = Progress_Params{
// token = token,
// value = value,
// },
// }
// }
Error_Code :: enum {
/* JSON-RPC errors: */
Parse_Error = -32700,
Invalid_Error = -32600,
Method_Not_Found = -32601,
Invalid_Params = -32602,
Internal_Error = -32603,
/* LSP errors: */
// Received request or notification before receiving the `initialize` request.
Server_Not_Initialized = -32002,
Unknown_Error_Code = -32001,
// A request failed but it was syntactically correct, e.g the
// method name was known and the parameters were valid. The error
// message should contain human readable information about why
// the request failed.
Request_Failed = -32803,
// The server cancelled the request. This error code should
// only be used for requests that explicitly support being
// server cancellable.
Server_Cancelled = -32802,
// The server detected that the content of a document got
// modified outside normal conditions. A server should
// NOT send this error code if it detects a content change
// in it unprocessed messages. The result even computed
// on an older state might still be useful for the client.
//
// If a client decides that a result is not of any use anymore
// the client should cancel the request.
Content_Modified = -32801,
// The client has canceled a request and a server as detected
// the cancel.
Request_Cancelled = -32800,
}
Response_Error :: struct($T: typeid) {
code: Error_Code,
message: string,
data: Maybe(T),
}
Work_Done_Progress_Params :: struct {
work_done_token: Progress_Token `json:"workDoneToken"`,
}
Initialize_Params :: struct {
using _: Work_Done_Progress_Params,
process_id: Maybe(int) `json:"processId"`,
client_info: Maybe(Client_Info) `json:"clientInfo"`,
locale: Maybe(string),
root_path: Maybe(string) `json:"rootPath"`, // deprecated in favor of `rootUri`.
root_uri: Maybe(Document_URI) `json:"rootUri"`, // deprecated in favor of `workspaceFolders`.
initialization_option: Any `json:"initializationOptions"`,
capabilities: Client_Capabilities,
trace: Maybe(Trace_Value),
workspace_folders: Maybe([]Workspace_Folder) `json:"workspaceFolders"`,
}
Trace_Value :: distinct string
TRACE_OFF: Trace_Value: "off"
TRACE_MESSAGES: Trace_Value: "messages"
TRACE_VERBOSE: Trace_Value: "verbose"
Workspace_Folder :: struct {
uri: URI,
name: string,
}
Client_Info :: struct {
name: string,
version: Maybe(string),
}
Server_Info :: distinct Client_Info
Client_Capabilities :: struct {
workspace: Maybe(Workspace_Document_Client_Capabilities),
text_document: Maybe(Text_Document_Client_Capabilities) `json:"textDocument"`,
notebook_document: Maybe(Notebook_Document_Client_Capabilities) `json:"notebookDocument"`,
window: Maybe(Window_Client_Capabilities),
general: Maybe(General_Client_Capabilities),
experimental: Maybe(Any),
}
Workspace_Document_Client_Capabilities :: struct {
}
Text_Document_Client_Capabilities :: struct {
}
Notebook_Document_Client_Capabilities :: struct {
}
Window_Client_Capabilities :: struct {
}
General_Client_Capabilities :: struct {
}
Initialize_Result :: struct {
capabilities: Server_Capabilities,
server_info: Maybe(Server_Info) `json:"serverInfo"`,
}
Server_Capabilities :: struct {
text_document_sync: Text_Document_Sync_Options `json:"textDocumentSync"`,
completion_provider: Completion_Options `json:"completionProvider"`,
}
Text_Document_Sync_Options :: struct {
open_close: bool `json:"openClose"`,
change: Text_Document_Sync_Kind,
}
Completion_Options :: struct {
work_done_progress: bool `json:"workDoneProgress"`,
// The additional characters, beyond the defaults provided by the client (typically
// [a-zA-Z]), that should automatically trigger a completion request. For example
// `.` in JavaScript represents the beginning of an object property or method and is
// thus a good candidate for triggering a completion request.
//
// Most tools trigger a completion request automatically without explicitly
// requesting it using a keyboard shortcut (e.g. Ctrl+Space). Typically they
// do so when the user starts to type an identifier. For example if the user
// types `c` in a JavaScript file code complete will automatically pop up
// present `console` besides others as a completion item. Characters that
// make up identifiers don't need to be listed here.
trigger_characters: []string `json:"triggerCharacters"`,
// The list of all possible characters that commit a completion. This field
// can be used if clients don't support individual commit characters per
// completion item. See client capability
// `completion.completionItem.commitCharactersSupport`.
//
// If a server provides both `allCommitCharacters` and commit characters on
// an individual completion item the ones on the completion item win.
all_commit_characters: []string `json:"allCommitCharacters"`,
// The server provides support to resolve additional
// information for a completion item.
resolve_provider: bool `json:"resolveProvider"`,
// The server supports the following `CompletionItem` specific
// capabilities.
completion_item: struct {
// The server has support for completion item label
// details (see also `CompletionItemLabelDetails`) when receiving
// a completion item in a resolve call.
label_details_support: bool `json:"labelDetailsSupport"`,
} `json:"completionItem"`,
}
Text_Document_Sync_Kind :: enum {
None,
// Documents are synced by always sending the full content
// of the document.
Full,
// Documents are synced by sending the full content on open.
// After that only incremental updates to the document are
// sent.
Incremental,
}
Initialize_Error_Data :: struct {
retry: bool,
}
Did_Change_Params :: struct {
text_document: Versioned_Text_Document_Identifier `json:"textDocument"`,
// The actual content changes. The content changes describe single state
// changes to the document. So if there are two content changes c1 (at
// array index 0) and c2 (at array index 1) for a document in state S then
// c1 moves the document from S to S' and c2 from S' to S''. So c1 is
// computed on the state S and c2 is computed on the state S'.
//
// To mirror the content of a document using change events use the following
// approach:
// - start with the same initial content
// - apply the 'textDocument/didChange' notifications in the order you
// receive them.
// - apply the `TextDocumentContentChangeEvent`s in a single notification
// in the order you receive them.
content_changes: []Text_Document_Content_Change_Event `json:"contentChanges"`,
}
Text_Document_Content_Change_Event :: struct {
range: Maybe(Range),
text: string,
}
Text_Document_Identifier :: struct {
uri: Document_URI,
}
Completion_Params :: struct {
work_done_token: Progress_Token `json:"workDoneToken"`,
text_document: Text_Document_Identifier `json:"textDocument"`,
position: Position,
partial_results_token: Progress_Token `json:"partialResultsToken"`,
ctx: Maybe(Completion_Context) `json:"context"`,
}
Completion_Context :: struct {
trigger_kind: Completion_Trigger_Kind,
// The trigger character (a single character) that has trigger code
// complete. Is undefined if
// `triggerKind !== CompletionTriggerKind.TriggerCharacter`
trigger_characters: Maybe(string),
}
Completion_Trigger_Kind :: enum {
Invoked = 1,
Trigger_Character = 2,
Trigger_For_Incomplete_Completions = 3,
}
Completion_List :: struct {
// This list is not complete. Further typing should result in recomputing
// this list.
//
// Recomputed lists have all their items replaced (not appended) in the
// incomplete completion sessions.
is_incomplete: bool `json:"isIncomplete"`,
// NOTE: skipping itemDefaults property.
items: []Completion_Item,
}
Completion_Item :: struct {
label: string,
label_details: Maybe(Completion_Item_Label_Details) `json:"labelDetails"`,
kind: Completion_Item_Kind,
tags: Maybe([]Completion_Item_Tag),
// A human-readable string with additional information
// about this item, like type or symbol information.
detail: Maybe(string),
documentation: union{ string, Markup_Content },
// Select this item when showing.
//
// *Note* that only one completion item can be selected and that the
// tool / client decides which item that is. The rule is that the *first*
// item of those that match best is selected.
preselect: Maybe(bool),
// A string that should be used when comparing this item
// with other items. When omitted the label is used
// as the sort text for this item.
sort_text: Maybe(string) `json:"sortText"`,
// A string that should be used when filtering a set of
// completion items. When omitted the label is used as the
// filter text for this item.
filter_text: Maybe(string) `json:"filterText"`,
// A string that should be inserted into a document when selecting
// this completion. When omitted the label is used as the insert text
// for this item.
//
// The `insertText` is subject to interpretation by the client side.
// Some tools might not take the string literally. For example
// VS Code when code complete is requested in this example
// `con<cursor position>` and a completion item with an `insertText` of
// `console` is provided it will only insert `sole`. Therefore it is
// recommended to use `textEdit` instead since it avoids additional client
// side interpretation.
insert_text: Maybe(string) `json:"insertText"`,
// The format of the insert text. The format applies to both the
// `insertText` property and the `newText` property of a provided
// `textEdit`. If omitted defaults to `InsertTextFormat.PlainText`.
//
// Please note that the insertTextFormat doesn't apply to
// `additionalTextEdits`.
insert_text_format: Insert_Text_Format `json:"insertTextFormat"`,
// How whitespace and indentation is handled during completion
// item insertion. If not provided the client's default value depends on
// the `textDocument.completion.insertTextMode` client capability.
insert_text_mode: Insert_Text_Mode `json:"insertTextMode"`,
// An edit which is applied to a document when selecting this completion.
// When an edit is provided the value of `insertText` is ignored.
//
// *Note:* The range of the edit must be a single line range and it must
// contain the position at which completion has been requested.
//
// Most editors support two different operations when accepting a completion
// item. One is to insert a completion text and the other is to replace an
// existing text with a completion text. Since this can usually not be
// predetermined by a server it can report both ranges. Clients need to
// signal support for `InsertReplaceEdit`s via the
// `textDocument.completion.completionItem.insertReplaceSupport` client
// capability property.
//
// *Note 1:* The text edit's range as well as both ranges from an insert
// replace edit must be a [single line] and they must contain the position
// at which completion has been requested.
// *Note 2:* If an `InsertReplaceEdit` is returned the edit's insert range
// must be a prefix of the edit's replace range, that means it must be
// contained and starting at the same position.
text_edit: union{ Text_Edit, Insert_Replace_Edit } `json:"textEdit"`,
// NOTE: relies on `itemDefaults` which we have skipped above.
// text_edit_text: Maybe(string),
additional_text_edits: []Text_Edit `json:"additionalTextEdits"`,
// An optional set of characters that when pressed while this completion is
// active will accept it first and then type that character. *Note* that all
// commit characters should have `length=1` and that superfluous characters
// will be ignored.
commit_characters: []string `json:"commitCharacters"`,
// An optional command that is executed *after* inserting this completion.
// *Note* that additional modifications to the current document should be
// described with the additionalTextEdits-property.
command: Maybe(Command),
// A data entry field that is preserved on a completion item between
// a completion and a completion resolve request.
data: Any,
}
Completion_Item_Label_Details :: struct {
// An optional string which is rendered less prominently directly after
// {@link CompletionItem.label label}, without any spacing. Should be
// used for function signatures or type annotations.
detail: Maybe(string),
// An optional string which is rendered less prominently after
// {@link CompletionItemLabelDetails.detail}. Should be used for fully qualified
// names or file path.
description: Maybe(string),
}
Completion_Item_Kind :: enum {
Text = 1,
Method,
Function,
Constructor,
Field,
Variable,
Class,
Interface,
Module,
Property,
Unit,
Value,
Enum,
Keyword,
Snippet,
Color,
File,
Reference,
Folder,
Enum_Member,
Constant,
Struct,
Event,
Operator,
Type_Parameter,
}
Completion_Item_Tag :: enum {
Deprecated = 1,
}
Markup_Content :: struct {
kind: Markup_Kind,
value: string,
}
Markup_Kind :: distinct string
MARKUP_KIND_MARKDOWN: Markup_Kind: "markdown"
MARKUP_KIND_PLAINTEXT: Markup_Kind: "plaintext"
Insert_Text_Format :: enum {
Plain_Text = 1,
Snippet = 2,
}
Insert_Text_Mode :: enum {
As_Is = 1,
Adjust_Indentation = 2,
}
Text_Edit :: struct {
range: Range,
new_text: string `json:"newText"`,
}
Insert_Replace_Edit :: struct {
new_text: string `json:"newText"`,
insert: Range,
replace: Range,
}
Command :: struct {
title: string,
command: string,
arguments: []Any,
}
Did_Open_Params :: struct {
text_document: Text_Document_Item `json:"textDocument"`,
}
Did_Close_Params :: struct {
text_document: Text_Document_Item `json:"textDocument"`,
}
Text_Document_Item :: struct {
uri: Document_URI,
language_id: string `json:"languageId"`,
version: int,
text: string,
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment