Last active
January 18, 2024 03:19
-
-
Save laytan/e5a474b5a6864ccbe802f477d7e5a7eb to your computer and use it in GitHub Desktop.
Incomplete LSP example Odin
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, ¬i, 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, ¬i, 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, ¬i, 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) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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