Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ package format · 0004 compiling procedure bodies · 0005 mutable pairs · 0006
as abnormal passes · 0007 identity for derived combiners · 0008 caching compiled
bodies (proposed, deliberately not built) · 0009 editor tooling from the runtime
outward (`ik check --json`, environment enumeration, the `ik lsp` language
server, reader recovery, and the environment/profile views are its phases 1–5).
server, reader recovery, the environment/profile views, and the `ik session`
protocol are its phases 1–6; the debug adapter itself stays proposed).

They record measurements and rejected options, not just decisions. When a prediction
in one turns out wrong, correct it in place rather than leaving it — several carry
Expand Down
1 change: 1 addition & 0 deletions IronKernel.Tests/IronKernel.Tests.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
<Compile Include="CliTests.fs" />
<Compile Include="CheckTests.fs" />
<Compile Include="LspTests.fs" />
<Compile Include="SessionTests.fs" />
<Compile Include="ProjectTests.fs" />
<Compile Include="ContinuationTests.fs" />
<Compile Include="EffectTests.fs" />
Expand Down
114 changes: 114 additions & 0 deletions IronKernel.Tests/SessionTests.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
module IronKernel.Tests.SessionTests

open System.IO
open System.Text
open System.Text.Json
open Xunit

open IronKernel.Ast
open IronKernel.Session

let private frame (stream: Stream) (json: string) =
let bytes = Encoding.UTF8.GetBytes json
let header = Encoding.ASCII.GetBytes(sprintf "Content-Length: %d\r\n\r\n" bytes.Length)
stream.Write(header, 0, header.Length)
stream.Write(bytes, 0, bytes.Length)

let private readFrames (data: byte[]) =
let text = Encoding.UTF8.GetString data
let mutable index = 0
let frames = ResizeArray()
while index < text.Length do
let headerEnd = text.IndexOf("\r\n\r\n", index, System.StringComparison.Ordinal)
let length =
text.Substring(index, headerEnd - index).Split("\r\n")
|> Array.pick (fun line ->
if line.StartsWith("Content-Length:", System.StringComparison.OrdinalIgnoreCase) then
Some(int (line.Substring("Content-Length:".Length).Trim()))
else None)
let bodyStart = Encoding.UTF8.GetByteCount(text.Substring(0, headerEnd)) + 4
frames.Add(JsonDocument.Parse(Encoding.UTF8.GetString(data, bodyStart, length)))
index <- (Encoding.UTF8.GetString(data, 0, bodyStart + length)).Length
List.ofSeq frames

let private session profile (messages: string list) =
use input = new MemoryStream()
messages |> List.iter (frame input)
input.Position <- 0L
use output = new MemoryStream()
let exitCode = runOn input output profile
Assert.Equal(0, exitCode)
readFrames (output.ToArray())

let private request id method' parameters =
sprintf """{"jsonrpc":"2.0","id":%d,"method":"%s","params":%s}""" (id: int) (method': string) (parameters: string)

let private evalRequest id (code: string) =
request id "eval" (sprintf """{"code":%s}""" (JsonSerializer.Serialize code))

let private resultOf id (frames: JsonDocument list) =
frames
|> List.pick (fun frameDocument ->
let root = frameDocument.RootElement
match root.TryGetProperty "id" with
| true, value when value.ValueKind = JsonValueKind.Number && value.GetInt32() = id ->
match root.TryGetProperty "result" with
| true, result -> Some result
| _ -> Some(root.GetProperty "error")
| _ -> None)

[<Fact>]
let ``a session evaluates persistently and separates errors from values`` () =
let frames =
session Unrestricted
[ request 1 "initialize" "{}"
evalRequest 2 "(define x 40)\n(+ x 2)"
evalRequest 3 "x"
evalRequest 4 "(car 5)"
request 5 "reset" "{}"
evalRequest 6 "x" ]
Assert.Equal("unrestricted", (resultOf 1 frames).GetProperty("profile").GetString())

// A multi-form eval returns the last value; the define persists.
Assert.Equal("42", (resultOf 2 frames).GetProperty("value").GetString())
Assert.Equal("40", (resultOf 3 frames).GetProperty("value").GetString())

// The phase-6 point: an error is a structured field, not stdout text.
let error = (resultOf 4 frames).GetProperty "error"
Assert.Contains("expected pair", error.GetProperty("message").GetString())
let location = error.GetProperty("locations").[0]
Assert.Equal("session", location.GetProperty("file").GetString())
Assert.Equal(1, location.GetProperty("range").GetProperty("start").GetProperty("line").GetInt32())
let mutable value = Unchecked.defaultof<JsonElement>
Assert.False((resultOf 4 frames).TryGetProperty("value", &value))

// Reset restores the bootstrap: the definition is gone.
let afterReset = resultOf 6 frames
Assert.Contains("unbound variable", afterReset.GetProperty("error").GetProperty("message").GetString())

[<Fact>]
let ``printed output travels in the response not the channel`` () =
let frames =
session Unrestricted
[ evalRequest 1 "(print \"hello, session\")" ]
let result = resultOf 1 frames
Assert.Equal("hello, session", result.GetProperty("output").GetString())
Assert.True(result.GetProperty("inert").GetBoolean())

[<Fact>]
let ``the session honours the capability profile`` () =
let frames =
session Minimal
[ request 1 "initialize" "{}"
evalRequest 2 "(print \"denied\")" ]
Assert.Equal("minimal", (resultOf 1 frames).GetProperty("profile").GetString())
// Under minimal there is no print binding at all: host output is authority.
let error = (resultOf 2 frames).GetProperty "error"
Assert.Contains("print", error.GetProperty("message").GetString())

[<Fact>]
let ``unknown session requests get a method-not-found error`` () =
let frames =
session Unrestricted [ request 1 "step" "{}" ]
let root = frames.Head.RootElement
Assert.Equal(-32601, root.GetProperty("error").GetProperty("code").GetInt32())
2 changes: 2 additions & 0 deletions IronKernel/IronKernel.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
<Compile Include="Repl.fs" />
<Compile Include="Project.fs" />
<Compile Include="Check.fs" />
<Compile Include="Jsonrpc.fs" />
<Compile Include="Session.fs" />
<Compile Include="LanguageServer.fs" />
<Compile Include="Program.fs" />
<!-- Pack=false: keep stdlib in the tool output (tools/net*/any) only, not as package contentFiles. -->
Expand Down
111 changes: 111 additions & 0 deletions IronKernel/Jsonrpc.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
namespace IronKernel

/// Content-Length framed JSON-RPC over streams: the transport the language
/// server (`ik lsp`) and the session protocol (`ik session`) share. A
/// deliberately small hand-rolled layer -- framing, requests, responses,
/// notifications -- kept dependency-free and testable against in-memory
/// streams (ADR 0009 phases 3 and 6).
module Jsonrpc =

open System
open System.IO
open System.Text
open System.Text.Json

let readFramed (input: Stream) : JsonDocument option =
let readLine () =
let builder = StringBuilder()
let mutable eof = false
let mutable finished = false
while not finished do
match input.ReadByte() with
| -1 ->
eof <- true
finished <- true
| 10 -> finished <- true
| 13 -> ()
| b -> builder.Append(char b) |> ignore
if eof && builder.Length = 0 then None else Some(builder.ToString())
let mutable contentLength = -1
let rec readHeaders () =
match readLine () with
| None -> false
| Some "" -> true
| Some line ->
if line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase) then
match Int32.TryParse(line.Substring("Content-Length:".Length).Trim()) with
| true, length -> contentLength <- length
| _ -> ()
readHeaders ()
if not (readHeaders ()) || contentLength < 0 then None
else
let buffer = Array.zeroCreate contentLength
let mutable filled = 0
let mutable ok = true
while ok && filled < contentLength do
let count = input.Read(buffer, filled, contentLength - filled)
if count <= 0 then ok <- false else filled <- filled + count
if ok then Some(JsonDocument.Parse(ReadOnlyMemory buffer)) else None

let writeFramed (output: Stream) (json: string) =
let bytes = Encoding.UTF8.GetBytes json
let header = Encoding.ASCII.GetBytes(sprintf "Content-Length: %d\r\n\r\n" bytes.Length)
output.Write(header, 0, header.Length)
output.Write(bytes, 0, bytes.Length)
output.Flush()

let toJson (write: Utf8JsonWriter -> unit) =
use stream = new MemoryStream()
use writer = new Utf8JsonWriter(stream)
write writer
writer.Flush()
Encoding.UTF8.GetString(stream.ToArray())

let respond output (id: JsonElement) (writeResult: Utf8JsonWriter -> unit) =
toJson (fun writer ->
writer.WriteStartObject()
writer.WriteString("jsonrpc", "2.0")
writer.WritePropertyName "id"
id.WriteTo writer
writer.WritePropertyName "result"
writeResult writer
writer.WriteEndObject())
|> writeFramed output

let respondError output (id: JsonElement) code (message: string) =
toJson (fun writer ->
writer.WriteStartObject()
writer.WriteString("jsonrpc", "2.0")
writer.WritePropertyName "id"
id.WriteTo writer
writer.WritePropertyName "error"
writer.WriteStartObject()
writer.WriteNumber("code", (code: int))
writer.WriteString("message", message)
writer.WriteEndObject()
writer.WriteEndObject())
|> writeFramed output

let notify output (method: string) (writeParams: Utf8JsonWriter -> unit) =
toJson (fun writer ->
writer.WriteStartObject()
writer.WriteString("jsonrpc", "2.0")
writer.WriteString("method", method)
writer.WritePropertyName "params"
writeParams writer
writer.WriteEndObject())
|> writeFramed output

/// The method name, id (when present), and params of one message.
let envelope (document: JsonDocument) =
let root = document.RootElement
let methodName =
match root.TryGetProperty "method" with
| true, value -> value.GetString()
| _ -> ""
let hasId, id = root.TryGetProperty "id"
let parameters =
match root.TryGetProperty "params" with
| true, value -> value
| _ -> JsonDocument.Parse("null").RootElement
methodName, hasId, id, parameters
98 changes: 2 additions & 96 deletions IronKernel/LanguageServer.fs
Original file line number Diff line number Diff line change
Expand Up @@ -18,92 +18,7 @@ module LanguageServer =
open System.Text.Json
open Ast
open Errors

// ---- Framing ----------------------------------------------------------

let private readFramed (input: Stream) : JsonDocument option =
let readLine () =
let builder = StringBuilder()
let mutable eof = false
let mutable finished = false
while not finished do
match input.ReadByte() with
| -1 ->
eof <- true
finished <- true
| 10 -> finished <- true
| 13 -> ()
| b -> builder.Append(char b) |> ignore
if eof && builder.Length = 0 then None else Some(builder.ToString())
let mutable contentLength = -1
let rec readHeaders () =
match readLine () with
| None -> false
| Some "" -> true
| Some line ->
if line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase) then
match Int32.TryParse(line.Substring("Content-Length:".Length).Trim()) with
| true, length -> contentLength <- length
| _ -> ()
readHeaders ()
if not (readHeaders ()) || contentLength < 0 then None
else
let buffer = Array.zeroCreate contentLength
let mutable filled = 0
let mutable ok = true
while ok && filled < contentLength do
let count = input.Read(buffer, filled, contentLength - filled)
if count <= 0 then ok <- false else filled <- filled + count
if ok then Some(JsonDocument.Parse(ReadOnlyMemory buffer)) else None

let private writeFramed (output: Stream) (json: string) =
let bytes = Encoding.UTF8.GetBytes json
let header = Encoding.ASCII.GetBytes(sprintf "Content-Length: %d\r\n\r\n" bytes.Length)
output.Write(header, 0, header.Length)
output.Write(bytes, 0, bytes.Length)
output.Flush()

let private toJson (write: Utf8JsonWriter -> unit) =
use stream = new MemoryStream()
use writer = new Utf8JsonWriter(stream)
write writer
writer.Flush()
Encoding.UTF8.GetString(stream.ToArray())

let private respond output (id: JsonElement) (writeResult: Utf8JsonWriter -> unit) =
toJson (fun writer ->
writer.WriteStartObject()
writer.WriteString("jsonrpc", "2.0")
writer.WritePropertyName "id"
id.WriteTo writer
writer.WritePropertyName "result"
writeResult writer
writer.WriteEndObject())
|> writeFramed output

let private respondError output (id: JsonElement) code (message: string) =
toJson (fun writer ->
writer.WriteStartObject()
writer.WriteString("jsonrpc", "2.0")
writer.WritePropertyName "id"
id.WriteTo writer
writer.WritePropertyName "error"
writer.WriteStartObject()
writer.WriteNumber("code", (code: int))
writer.WriteString("message", message)
writer.WriteEndObject()
writer.WriteEndObject())
|> writeFramed output

let private notify output (method: string) (writeParams: Utf8JsonWriter -> unit) =
toJson (fun writer ->
writer.WriteStartObject()
writer.WriteString("jsonrpc", "2.0")
writer.WriteString("method", method)
writer.WritePropertyName "params"
writeParams writer
writer.WriteEndObject())
|> writeFramed output
open Jsonrpc

// ---- Positions --------------------------------------------------------

Expand Down Expand Up @@ -526,16 +441,7 @@ module LanguageServer =
| None -> running <- false
| Some document ->
use document = document
let root = document.RootElement
let methodName =
match root.TryGetProperty "method" with
| true, value -> value.GetString()
| _ -> ""
let hasId, id = root.TryGetProperty "id"
let parameters =
match root.TryGetProperty "params" with
| true, value -> value
| _ -> JsonDocument.Parse("null").RootElement
let methodName, hasId, id, parameters = envelope document
match methodName with
| "initialize" ->
respond output id (fun writer ->
Expand Down
2 changes: 2 additions & 0 deletions IronKernel/Program.fs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ let private usage =
ironkernel [--profile <profile>] run <file> [args...] Run a .ikr script or .ikc package
ironkernel [--profile <profile>] check [--json] [<file.ikr> | project.ikproj]
ironkernel [--profile <profile>] lsp Start the language server (stdio)
ironkernel [--profile <profile>] session Start an evaluation session (stdio)
ironkernel [--profile <profile>] compile <file.ikr> [-o <file.ikc>]
ironkernel [--profile <profile>] compile <file.ikr> --managed [-o <directory>]
ironkernel --profile <minimal|safe> compile <file.ikr> --native <rid> [-o <directory>]
Expand Down Expand Up @@ -162,6 +163,7 @@ let private dispatch (profileOverride: CapabilityProfile option) args =
// Non-source tokens (including flags) are project program args, not scripts.
withProject profileOverride None (fun project -> ProjectTool.run project scriptArgs)
| ["lsp"] -> IronKernel.LanguageServer.run profile
| ["session"] -> IronKernel.Session.run profile
| "check" :: rest ->
let json = List.contains "--json" rest
let arguments = rest |> List.filter (fun argument -> argument <> "--json")
Expand Down
Loading
Loading