Pudu programming language
Menu
Package

@chrismichaelps / pudu-lang-mcp

Model Context Protocol server for Pudu language documentation and compiler tools

0.1.1Apache-2.01

InstallClose

Conversation.pudu

Pudu139 lines5.5 KB

GitHub ↗
1/** @Domain.Lsp.Conversation.Module — one-shot language server conversations */2module PuduLangMcp.Domain.Lsp.Conversation34import Std.Json as Json5import Std.Math as Math6import Std.Text as Text7import PuduLangMcp.Utils.JsonAccess as Access89/** @Domain.Lsp.Conversation.Question — one question at 1-based position */10export type Question = { method: Str, line: Int, character: Int, newName: Str, query: Str }1112/** @Domain.Lsp.Conversation.Answer — result and diagnostics from a transcript */13export type Answer = { result: Option[Json.Json], diagnostics: Array[Json.Json], error: Option[Str] }1415/// Request id of the handshake.16const INITIALIZE_ID: Int = 117/// Request id of the question.18const QUESTION_ID: Int = 219/// Request id of the goodbye.20const SHUTDOWN_ID: Int = 321/// Completion trigger kind for an explicit request.22const INVOKED: Int = 123/// The header that starts every framed message.24const HEADER: Str = "Content-Length:"25/// What ends a message's headers.26const SEPARATOR: Str = "\r\n\r\n"27/// The one method asked without an open document.28const WORKSPACE_SYMBOL: Str = "workspace/symbol"29/// The notification carrying a document's diagnostics.30const DIAGNOSTICS: Str = "textDocument/publishDiagnostics"3132/// Every framed message of one question, from handshake to exit.33export fn conversation(rootUri: Str, uri: Str, source: Str, question: &Question) -> Str {34  var messages = [35    request(INITIALIZE_ID, "initialize", Json.object(&[36          ("processId", Json.Null),37          ("rootUri", Json.Text(rootUri)),38          ("capabilities", Json.object(&[]))39        ])),40    notification("initialized", Json.object(&[]))41  ]42  let opens = !(question.method == WORKSPACE_SYMBOL && source.isEmpty())43  if opens {44    messages = messages.push(notification("textDocument/didOpen", Json.object(&[45            ("textDocument", Json.object(&[46                  ("uri", Json.Text(uri)),47                  ("languageId", Json.Text("pudu")),48                  ("version", Json.Number(1)),49                  ("text", Json.Text(source))50                ]))51          ])))52  }53  messages = messages.push(request(QUESTION_ID, question.method, params(uri, question)))54  messages = messages.push(request(SHUTDOWN_ID, "shutdown", Json.Null))55  messages = messages.push(notification("exit", Json.Null))56  messages.map(|message: Json.Json| frame(&message)).join("")57}5859/// The question's parameters, with the position made 0-based.60export fn params(uri: Str, question: &Question) -> Json.Json {61  if question.method == WORKSPACE_SYMBOL { return Json.object(&[("query", Json.Text(question.query))]) }62  let document = ("textDocument", Json.object(&[("uri", Json.Text(uri))]))63  if question.method == "textDocument/documentSymbol" { return Json.object(&[document]) }64  let at = position(question)65  if question.method == "textDocument/codeAction" {66    return Json.object(&[67        document,68        ("range", Json.object(&[("start", at), ("end", at)])),69        ("context", Json.object(&[("diagnostics", Json.list(&[]))]))70      ])71  }72  var fields = [document, ("position", at)]73  if question.method == "textDocument/references" {74    fields = fields.push(("context", Json.object(&[("includeDeclaration", Json.Boolean(true))])))75  }76  if question.method == "textDocument/completion" {77    fields = fields.push(("context", Json.object(&[("triggerKind", Json.Number(INVOKED))])))78  }79  if question.method == "textDocument/rename" {80    fields = fields.push(("newName", Json.Text(question.newName)))81  }82  Json.object(&fields)83}8485/// The question's answer and the published diagnostics in a transcript.86export fn answerIn(transcript: Str) -> Answer {87  var result: Option[Json.Json] = None88  var error: Option[Str] = None89  var diagnostics: Array[Json.Json] = []90  for piece in transcript.split(HEADER) {91    if let Some(start) = Text.find(piece, SEPARATOR) {92      match Json.decode(piece.drop(start + SEPARATOR.length()).trim()) {93        case Ok(message) => {94          if Access.integer(&message, "id") == Some(QUESTION_ID) {95            match Access.member(&message, "error") {96              case Some(problem) => { error = Some(match Access.text(&problem, "message") { case Some(text) => text case None => "the language server refused the request" }) }97              case None => { result = Some(match Access.member(&message, "result") { case Some(value) => value case None => Json.Null }) }98            }99          }100          if Access.text(&message, "method") == Some(DIAGNOSTICS) {101            match Json.path(&message, &["params", "diagnostics"]) {102              case Some(Json.List(items)) => { diagnostics = items }103              case _ => ()104            }105          }106        }107        case Err(_) => ()108      }109    }110  }111  Answer{result: result, diagnostics: diagnostics, error: error}112}113114/// One message with its `Content-Length` header.115export fn frame(message: &Json.Json) -> Str {116  let body = Json.encode(message)117  HEADER + " " + show(body.toBytes().length()) + SEPARATOR + body118}119120/// A 0-based LSP position from the question's 1-based one.121fn position(question: &Question) -> Json.Json {122  Json.object(&[("line", Json.Number(Math.max(question.line - 1, 0))), ("character", Json.Number(Math.max(question.character - 1, 0)))])123}124125/// A request message.126fn request(id: Int, method: Str, parameters: Json.Json) -> Json.Json {127  Json.object(&[128      ("jsonrpc", Json.Text("2.0")),129      ("id", Json.Number(id)),130      ("method", Json.Text(method)),131      ("params", parameters)132    ])133}134135/// A notification message.136fn notification(method: Str, parameters: Json.Json) -> Json.Json {137  Json.object(&[("jsonrpc", Json.Text("2.0")), ("method", Json.Text(method)), ("params", parameters)])138}139