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

ToolsTest.pudu

Pudu207 lines17.4 KB

GitHub ↗
1/** @Test.App.ToolsTest.Suite — tool handlers against a recording toolchain */2module PuduLangMcp.App.ToolsTest34import Std.Env as Env5import Std.Fs as Fs6import Std.Io as Io7import Std.Json as Json8import Std.List as List9import Std.Option as Option10import Std.Result as Result11import Std.Sync as Sync12import Std.Test as Test13import PuduLangMcp.App.Context as Context14import PuduLangMcp.App.Tools.Registry as Registry15import PuduLangMcp.Domain.Lsp.Conversation as Conversation16import PuduLangMcp.Generated.Docs as Docs17import PuduLangMcp.Services.Process.Bounded as Bounded18import PuduLangMcp.Services.Toolchain as Toolchain19import PuduLangMcp.Services.Workspace as Workspace20import PuduLangMcp.Utils.JsonAccess as Access2122/** @Test.App.ToolsTest.Call — one recorded toolchain call */23type Call = { arguments: Array[Str], input: Str, directory: Str }2425/// A language-server transcript answering the question with `result`.26fn transcript(result: Json.Json) -> Str {27  Conversation.frame(&Json.object(&[("jsonrpc", Json.Text("2.0")), ("id", Json.Number(2)), ("result", result)]))28}2930/// A toolchain that records every call and answers by command.31fn recording(calls: &Sync.Cell[Array[Call]], library: Str) -> Toolchain.Toolchain {32  let log = *calls33  Toolchain.scripted("0.1.1", Some(library), fn(arguments: Array[Str], input: Str, directory: Str) -> Bounded.Finished {34      let _logged = Sync.set(&log, Result.unwrapOr(Sync.get(&log), []).push(Call{arguments: arguments, input: input, directory: directory}))35      let command = Option.unwrapOr(List.first(&arguments), "")36      if command == "check" { return Toolchain.finished(1, directory + "/Main.pudu:3:1 error[E3001]: expected Int") }37      if command == "fmt" { return if input.isEmpty() && arguments.contains("bad.pudu") { Toolchain.finished(1, "error[E1001]") } else { Toolchain.finished(0, "module Main\n") } }38      if command == "run" { return Bounded.Finished{status: 0, output: "hello", errors: "", timedOut: arguments.contains("Slow.pudu"), truncated: false, millis: 5} }39      if command == "lsp" {40        if input.contains("textDocument/hover") { return Toolchain.finished(0, transcript(Json.object(&[("contents", Json.object(&[("kind", Json.Text("markdown")), ("value", Json.Text("double : fn(Int) -> Int"))]))]))) }41        if input.contains("workspace/symbol") { return Toolchain.finished(0, transcript(Json.list(&[Json.object(&[("name", Json.Text("area")), ("kind", Json.Number(12)), ("location", Json.object(&[("uri", Json.Text("file://" + directory + "/src/Shapes.pudu")), ("range", Json.object(&[("start", Json.object(&[("line", Json.Number(0)), ("character", Json.Number(0))])), ("end", Json.object(&[("line", Json.Number(0)), ("character", Json.Number(4))]))]))]))])]))) }42        if input.contains("textDocument/definition") && input.contains("LIBRARY_TARGET") { return Toolchain.finished(0, transcript(location("file://" + library + "/Std/Io.pudu", 0))) }43        if input.contains("textDocument/definition") && input.contains("OUTSIDE_TARGET") { return Toolchain.finished(0, transcript(location("file:///etc/hosts", 0))) }44        if input.contains("textDocument/definition") && input.contains("FAR_TARGET") { return Toolchain.finished(0, transcript(location("file://" + directory + "/Main.pudu", 400))) }45        if input.contains("textDocument/definition") && input.contains("EXACT_TARGET") { return Toolchain.finished(0, transcript(location("file://" + directory + "/Main.pudu", 3))) }46        if input.contains("textDocument/definition") && input.contains("module Shapes") { return Toolchain.finished(0, transcript(location("file://" + directory + "/src/Shapes.pudu", 0))) }47        if input.contains("textDocument/definition") { return Toolchain.finished(0, transcript(Json.object(&[("uri", Json.Text("file://" + directory + "/Main.pudu")), ("range", Json.object(&[("start", Json.object(&[("line", Json.Number(2)), ("character", Json.Number(3))])), ("end", Json.object(&[("line", Json.Number(2)), ("character", Json.Number(9))]))]))]))) }48        if input.contains("textDocument/references") { return Toolchain.finished(0, Conversation.frame(&Json.object(&[("jsonrpc", Json.Text("2.0")), ("id", Json.Number(2)), ("error", Json.object(&[("code", Json.Number(-32603)), ("message", Json.Text("index not ready"))]))]))) }49        return Toolchain.finished(0, "")50      }51      if command == "api" { return Toolchain.finished(0, "\{\"exports\":[]\}") }52      if command == "doc" { return Toolchain.finished(0, "\{\"entries\":[]\}") }53      Toolchain.finished(0, "ran " + arguments.join(" "))54    })55}5657/// A definition result at a 0-based line of a file.58fn location(uri: Str, line: Int) -> Json.Json {59  Json.object(&[("uri", Json.Text(uri)), ("range", Json.object(&[("start", Json.object(&[("line", Json.Number(line)), ("character", Json.Number(0))])), ("end", Json.object(&[("line", Json.Number(line)), ("character", Json.Number(1))]))]))])60}6162/// Calls a tool and answers its text and whether it was an error.63fn call(context: &Context.Context, name: Str, arguments: &Array[(Str, Json.Json)]) -> (Str, Bool) {64  match Registry.call(context, &Json.object(&[("name", Json.Text(name)), ("arguments", Json.object(arguments))])) {65    case Ok(result) => {66      let text = match Json.path(&result, &["content"]) {67        case Some(content) => Option.unwrapOr(Option.andThen(Json.at(&content, 0), |block: Json.Json| Access.text(&block, "text")), "")68        case None => ""69      }70      (text, Json.path(&result, &["isError"]) == Some(Json.Boolean(true)))71    }72    case Err(_) => ("protocol error", true)73  }74}7576/// The last recorded call.77fn last(calls: &Sync.Cell[Array[Call]]) -> Call {78  Option.unwrapOr(List.last(&Result.unwrapOr(Sync.get(calls), [])), Call{arguments: [], input: "", directory: ""})79}8081/// Runs the suite.82fn main() -> Int {83  let temporary = Env.temporaryDirectory()84  let library = Result.unwrapOr(Fs.temporaryDirectoryIn(temporary, "pudu-mcp-lib-"), "/nonexistent")85  let root = Result.unwrapOr(Fs.temporaryDirectoryIn(temporary, "pudu-mcp-ws-"), "/nonexistent")86  let outside = Result.unwrapOr(Fs.temporaryDirectoryIn(temporary, "pudu-mcp-out-"), "/nonexistent")87  let _libraryMade = Io.makeDirectory(library + "/Std")88  let _libraryFile = Io.write(library + "/Std/Io.pudu", "module Std.Io\n\nexport fn read() -> Int \{ 1 \}\n")89  let _sourceMade = Io.makeDirectory(root + "/src")90  let _sourceFile = Io.write(root + "/src/Shapes.pudu", "module Shapes\n")91  let _notes = Io.write(root + "/notes.txt", "plain")92  let _secret = Io.write(outside + "/Secret.pudu", "module Secret\n")93  let _linked = Fs.linkTo(outside + "/Secret.pudu", root + "/src/Escape.pudu")94  let calls = Sync.cell([])95  let context = Context.create(recording(&calls, library), Workspace.at(root), Docs.CHAPTERS, "test")96  let checked = call(&context, "pudu_check", &[("source", Json.Text("module Main\n\nfn main() -> Int \{ \"x\" \}\n"))])97  let checkCall = last(&calls)98  let scratchRemoved = !Io.exists(checkCall.directory)99  let placed = call(&context, "pudu_check", &[("source", Json.Text("module App.Greeting\n"))])100  let placedCall = last(&calls)101  let byPath = call(&context, "pudu_check", &[("path", Json.Text("src/Shapes.pudu"))])102  let byPathCall = last(&calls)103  let traversal = call(&context, "pudu_check", &[("path", Json.Text("../" + Option.unwrapOr(List.last(&outside.split("/")), "") + "/Secret.pudu"))])104  let absolute = call(&context, "pudu_check", &[("path", Json.Text(outside + "/Secret.pudu"))])105  let escaped = call(&context, "pudu_check", &[("path", Json.Text("src/Escape.pudu"))])106  let notSource = call(&context, "pudu_check", &[("path", Json.Text("notes.txt"))])107  let absent = call(&context, "pudu_check", &[("path", Json.Text("src/Nope.pudu"))])108  let both = call(&context, "pudu_check", &[("path", Json.Text("src/Shapes.pudu")), ("source", Json.Text("module Main"))])109  let formatted = call(&context, "pudu_format", &[("source", Json.Text("module Main\n"))])110  let ran = call(&context, "pudu_run", &[("source", Json.Text("module Main\n"))])111  let runCall = last(&calls)112  let slow = call(&context, "pudu_run", &[("source", Json.Text("module Slow\n")), ("timeoutMs", Json.Number(200))])113  let tooLong = call(&context, "pudu_run", &[("source", Json.Text("module Main\n")), ("timeoutMs", Json.Number(3600000))])114  let tested = call(&context, "pudu_test", &[])115  let testCall = last(&calls)116  let testedPath = call(&context, "pudu_test", &[("path", Json.Text("src"))])117  let testPathCall = last(&calls)118  let testedOutside = call(&context, "pudu_test", &[("path", Json.Text(outside))])119  let hovered = call(&context, "pudu_hover", &[("source", Json.Text("module Main\n")), ("line", Json.Number(1)), ("character", Json.Number(1))])120  let defined = call(&context, "pudu_definition", &[("source", Json.Text("module Main\n\nfn double(x: Int) -> Int \{ x * 2 \}\n")), ("line", Json.Number(1)), ("character", Json.Number(1))])121  let refused = call(&context, "pudu_references", &[("source", Json.Text("module Main\n")), ("line", Json.Number(1)), ("character", Json.Number(1))])122  let silent = call(&context, "pudu_completion", &[("source", Json.Text("module Main\n")), ("line", Json.Number(1)), ("character", Json.Number(1))])123  let noLine = call(&context, "pudu_hover", &[("source", Json.Text("module Main\n"))])124  let fromWorkspace = call(&context, "pudu_definition", &[("path", Json.Text("src/Shapes.pudu")), ("line", Json.Number(1)), ("character", Json.Number(1))])125  let intoLibrary = call(&context, "pudu_definition", &[("source", Json.Text("module Main\n// LIBRARY_TARGET\n")), ("line", Json.Number(1)), ("character", Json.Number(1))])126  let intoOutside = call(&context, "pudu_definition", &[("source", Json.Text("module Main\n// OUTSIDE_TARGET\n")), ("line", Json.Number(1)), ("character", Json.Number(1))])127  let pastEnd = call(&context, "pudu_definition", &[("source", Json.Text("module Main\n// FAR_TARGET\n")), ("line", Json.Number(1)), ("character", Json.Number(1))])128  let atEnd = call(&context, "pudu_definition", &[("source", Json.Text("module Main\n// EXACT_TARGET\n")), ("line", Json.Number(1)), ("character", Json.Number(1))])129  let noLibrary = Context.create(Toolchain.Toolchain{..recording(&calls, library), library: None}, Workspace.at(root), Docs.CHAPTERS, "test")130  let untrustedWithoutLibrary = call(&noLibrary, "pudu_definition", &[("source", Json.Text("module Main\n// OUTSIDE_TARGET\n")), ("line", Json.Number(1)), ("character", Json.Number(1))])131  let symbols = call(&context, "pudu_workspace_symbols", &[("query", Json.Text("are"))])132  let status = call(&context, "pudu_toolchain", &[])133  let bare = Context.create(Toolchain.Toolchain{compiler: None, library: None, version: "", run: fn(arguments: Array[Str], input: Str, directory: Str, millis: Int, capBytes: Int) -> Result[Bounded.Finished, Str] { Err("pudu was not found") }}, Workspace.at(root), Docs.CHAPTERS, "test")134  let missingCheck = call(&bare, "pudu_check", &[("source", Json.Text("module Main\n"))])135  let missingDocs = call(&bare, "pudu_docs_search", &[("query", Json.Text("ownership"))])136  let missingStatus = call(&bare, "pudu_toolchain", &[])137  let readSection = call(&context, "pudu_docs_read", &[("slug", Json.Text("errors")), ("section", Json.Text("working-with-results"))])138  let readMissing = call(&context, "pudu_docs_read", &[("slug", Json.Text("nope"))])139  let readBadGroup = call(&context, "pudu_docs_read", &[("slug", Json.Text("errors")), ("group", Json.Text("blog"))])140  let readBadSection = call(&context, "pudu_docs_read", &[("slug", Json.Text("errors")), ("section", Json.Text("nowhere"))])141  let example = call(&context, "pudu_docs_read", &[("slug", Json.Text("hello")), ("group", Json.Text("examples"))])142  let unknownModule = call(&context, "pudu_module_reference", &[("module", Json.Text("Io"))])143  let shape = call(&context, "pudu_reference_search", &[("query", Json.Text("Array[a] -> a"))])144  let shapeCall = last(&calls)145  let _cleanRoot = Fs.removeTree(root)146  let _cleanOutside = Fs.removeTree(outside)147  let _cleanLibrary = Fs.removeTree(library)148  let checks = Test.suite("App.Tools", &[149      Test.that("check answers the compiler's diagnostics", checked[0].contains("error[E3001]")),150      Test.not("diagnostics are an answer, not a tool error", checked[1]),151      Test.not("the scratch directory is hidden from output", checked[0].contains(checkCall.directory)),152      Test.equals("inline source is checked as Main.pudu", &checkCall.arguments, &["check", "Main.pudu"]),153      Test.that("the scratch directory is removed afterwards", scratchRemoved),154      Test.equals("inline source is placed at its module's path", &placedCall.arguments, &["check", "App/Greeting.pudu"]),155      Test.equals("a workspace file is checked by its relative path", &byPathCall.arguments, &["check", "src/Shapes.pudu"]),156      Test.not("a workspace file is not given a scratch directory", byPathCall.directory.contains("pudu-mcp-") && !byPathCall.directory.contains("pudu-mcp-ws-")),157      Test.that("a relative path out of the workspace is refused", traversal[1] && traversal[0].contains("inside the workspace")),158      Test.that("an absolute path out of the workspace is refused", absolute[1]),159      Test.that("a link out of the workspace is refused", escaped[1]),160      Test.that("a file that is not Pudu source is refused", notSource[1]),161      Test.that("a missing file is refused", absent[1]),162      Test.that("source and path together are refused", both[1] && both[0].contains("exactly one")),163      Test.equals("format answers the formatted code", &formatted, &("module Main", false)),164      Test.equals("a run is always confined", &List.take(&runCall.arguments, 2), &["run", "--confined"]),165      Test.that("a run answers its exit status and output", ran[0].startsWith("exit status 0") && ran[0].contains("hello")),166      Test.that("a stopped run says so and keeps its output", slow[0].startsWith("Stopped after 200 ms") && slow[0].contains("hello")),167      Test.that("a timeout above the ceiling is refused", tooLong[1] && tooLong[0].contains("timeoutMs")),168      Test.equals("tests run in the workspace root", &(testCall.arguments, testCall.directory), &(["test"], context.workspace.root)),169      Test.not("a test run is not an error", tested[1]),170      Test.equals("a test path is passed relative to the workspace", &testPathCall.arguments, &["test", "src"]),171      Test.that("a test path outside the workspace is refused", testedOutside[1]),172      Test.equals("hover answers the language server's text", &hovered, &("double : fn(Int) -> Int", false)),173      Test.that("a definition in inline source shows the source line", defined[0].startsWith("<source>:3:4") && defined[0].contains("fn double(x: Int)")),174      Test.equals("a language-server error is an answer", &refused, &("The language server answered: index not ready", false)),175      Test.that("a server that ends without answering is a tool error", silent[1] && silent[0].contains("without answering")),176      Test.that("a position question needs its line", noLine[1] && noLine[0].contains("'line'")),177      Test.equals("a workspace definition is shown by its workspace path", &fromWorkspace[0], &"src/Shapes.pudu:1:1\n```pudu\nmodule Shapes\n\n```"),178      Test.that("a library definition shows the library's source", intoLibrary[0].startsWith("Std/Io.pudu:1:1") && intoLibrary[0].contains("export fn read() -> Int")),179      Test.equals("a definition outside the workspace and library shows no source", &intoOutside[0], &"/etc/hosts:1:1"),180      Test.equals("a definition past the end of its file shows no source", &pastEnd[0], &"<source>:401:1"),181      Test.equals("a definition at the end of its file shows no source", &atEnd[0], &"<source>:4:1"),182      Test.equals("without a library an outside definition shows no source", &untrustedWithoutLibrary[0], &"/etc/hosts:1:1"),183      Test.not("the index wait has time before its deadline", Context.indexDeadlineReached(99, 100)),184      Test.that("the index wait expires at and after its deadline", Context.indexDeadlineReached(100, 100) && Context.indexDeadlineReached(101, 100)),185      Test.equals("workspace symbols are shown by workspace path", &symbols, &("function area — src/Shapes.pudu:1:1", false)),186      Test.that("a workspace file's diagnostics keep their path", byPath[0].contains(context.workspace.root)),187      Test.not("a finished command is not reported as stopped", checked[0].startsWith("Stopped")),188      Test.that("status names the compiler version", status[0].contains("pudu 0.1.1")),189      Test.that("without a compiler, check says how to install one", missingCheck[1] && missingCheck[0].contains("pudu-lang.org/download")),190      Test.not("without a compiler, documentation still works", missingDocs[1]),191      Test.that("without a compiler, status reports it missing", missingStatus[0].contains("compiler: not found")),192      Test.that("a section is read by its anchor", readSection[0].startsWith("## Working with results")),193      Test.that("an unknown document lists the known ones", readMissing[1] && readMissing[0].contains("ownership")),194      Test.that("an unknown group is refused", readBadGroup[1] && readBadGroup[0].contains("examples")),195      Test.that("an unknown section lists the anchors", readBadSection[1] && readBadSection[0].contains("working-with-results")),196      Test.that("examples are readable as fenced programs", example[0].contains("```pudu")),197      Test.that("an unknown module suggests near names", unknownModule[1] && unknownModule[0].contains("Std.Io")),198      Test.equals("a type shape goes to the compiler's search", &List.take(&shapeCall.arguments, 2), &["search", "Array[a] -> a"]),199      Test.not("a shape search is not an error", shape[1])200    ])201  let report = Test.run(&checks)202  for failure in Test.failuresOf(&report) {203    let _reported = Io.writeErrorLine(failure)204  }205  Test.report(&report)206}207