
ToolsTest.pudu
Pudu207 lines17.4 KB
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 }242526fn transcript(result: Json.Json) -> Str {27 Conversation.frame(&Json.object(&[("jsonrpc", Json.Text("2.0")), ("id", Json.Number(2)), ("result", result)]))28}293031fn 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}565758fn 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}616263fn 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}757677fn last(calls: &Sync.Cell[Array[Call]]) -> Call {78 Option.unwrapOr(List.last(&Result.unwrapOr(Sync.get(calls), [])), Call{arguments: [], input: "", directory: ""})79}808182fn 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