├── .gitignore ├── README.md ├── analysis └── state.go ├── go.mod ├── lsp ├── initialize.go ├── message.go ├── textdocument.go ├── textdocument_codeaction.go ├── textdocument_completion.go ├── textdocument_definition.go ├── textdocument_diagnostics.go ├── textdocument_didchange.go ├── textdocument_didopen.go └── textdocument_hover.go ├── main.go └── rpc ├── rpc.go └── rpc_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | main 2 | log.txt 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `educationalsp` 2 | 3 | A Language Server built for the educational purpose of understanding **WHAT** LSP is and **HOW** it works. 4 | 5 | It doesn't do anything special for any particular language, it is focused on helping you understand what your tools **do**. 6 | 7 | This LSP was tested with Neovim, but would likely work inside Neovim. 8 | -------------------------------------------------------------------------------- /analysis/state.go: -------------------------------------------------------------------------------- 1 | package analysis 2 | 3 | import ( 4 | "educationalsp/lsp" 5 | "fmt" 6 | "strings" 7 | ) 8 | 9 | type State struct { 10 | // Map of file names to contents 11 | Documents map[string]string 12 | } 13 | 14 | func NewState() State { 15 | return State{Documents: map[string]string{}} 16 | } 17 | 18 | func getDiagnosticsForFile(text string) []lsp.Diagnostic { 19 | diagnostics := []lsp.Diagnostic{} 20 | for row, line := range strings.Split(text, "\n") { 21 | if strings.Contains(line, "VS Code") { 22 | idx := strings.Index(line, "VS Code") 23 | diagnostics = append(diagnostics, lsp.Diagnostic{ 24 | Range: LineRange(row, idx, idx+len("VS Code")), 25 | Severity: 1, 26 | Source: "Common Sense", 27 | Message: "Please make sure we use good language in this video", 28 | }) 29 | } 30 | 31 | if strings.Contains(line, "Neovim") { 32 | idx := strings.Index(line, "Neovim") 33 | diagnostics = append(diagnostics, lsp.Diagnostic{ 34 | Range: LineRange(row, idx, idx+len("Neovim")), 35 | Severity: 2, 36 | Source: "Common Sense", 37 | Message: "Great choice :)", 38 | }) 39 | 40 | } 41 | } 42 | 43 | return diagnostics 44 | } 45 | 46 | func (s *State) OpenDocument(uri, text string) []lsp.Diagnostic { 47 | s.Documents[uri] = text 48 | 49 | return getDiagnosticsForFile(text) 50 | } 51 | 52 | func (s *State) UpdateDocument(uri, text string) []lsp.Diagnostic { 53 | s.Documents[uri] = text 54 | 55 | return getDiagnosticsForFile(text) 56 | } 57 | 58 | func (s *State) Hover(id int, uri string, position lsp.Position) lsp.HoverResponse { 59 | // In real life, this would look up the type in our type analysis code... 60 | 61 | document := s.Documents[uri] 62 | 63 | return lsp.HoverResponse{ 64 | Response: lsp.Response{ 65 | RPC: "2.0", 66 | ID: &id, 67 | }, 68 | Result: lsp.HoverResult{ 69 | Contents: fmt.Sprintf("File: %s, Characters: %d", uri, len(document)), 70 | }, 71 | } 72 | } 73 | 74 | func (s *State) Definition(id int, uri string, position lsp.Position) lsp.DefinitionResponse { 75 | // In real life, this would look up the definition 76 | 77 | return lsp.DefinitionResponse{ 78 | Response: lsp.Response{ 79 | RPC: "2.0", 80 | ID: &id, 81 | }, 82 | Result: lsp.Location{ 83 | URI: uri, 84 | Range: lsp.Range{ 85 | Start: lsp.Position{ 86 | Line: position.Line - 1, 87 | Character: 0, 88 | }, 89 | End: lsp.Position{ 90 | Line: position.Line - 1, 91 | Character: 0, 92 | }, 93 | }, 94 | }, 95 | } 96 | } 97 | func (s *State) TextDocumentCodeAction(id int, uri string) lsp.TextDocumentCodeActionResponse { 98 | text := s.Documents[uri] 99 | 100 | actions := []lsp.CodeAction{} 101 | for row, line := range strings.Split(text, "\n") { 102 | idx := strings.Index(line, "VS Code") 103 | if idx >= 0 { 104 | replaceChange := map[string][]lsp.TextEdit{} 105 | replaceChange[uri] = []lsp.TextEdit{ 106 | { 107 | Range: LineRange(row, idx, idx+len("VS Code")), 108 | NewText: "Neovim", 109 | }, 110 | } 111 | 112 | actions = append(actions, lsp.CodeAction{ 113 | Title: "Replace VS C*de with a superior editor", 114 | Edit: &lsp.WorkspaceEdit{Changes: replaceChange}, 115 | }) 116 | 117 | censorChange := map[string][]lsp.TextEdit{} 118 | censorChange[uri] = []lsp.TextEdit{ 119 | { 120 | Range: LineRange(row, idx, idx+len("VS Code")), 121 | NewText: "VS C*de", 122 | }, 123 | } 124 | 125 | actions = append(actions, lsp.CodeAction{ 126 | Title: "Censor to VS C*de", 127 | Edit: &lsp.WorkspaceEdit{Changes: censorChange}, 128 | }) 129 | } 130 | } 131 | 132 | response := lsp.TextDocumentCodeActionResponse{ 133 | Response: lsp.Response{ 134 | RPC: "2.0", 135 | ID: &id, 136 | }, 137 | Result: actions, 138 | } 139 | 140 | return response 141 | } 142 | 143 | func (s *State) TextDocumentCompletion(id int, uri string) lsp.CompletionResponse { 144 | 145 | // Ask your static analysis tools to figure out good completions 146 | items := []lsp.CompletionItem{ 147 | { 148 | Label: "Neovim (BTW)", 149 | Detail: "Very cool editor", 150 | Documentation: "Fun to watch in videos. Don't forget to like & subscribe to streamers using it :)", 151 | }, 152 | } 153 | 154 | response := lsp.CompletionResponse{ 155 | Response: lsp.Response{ 156 | RPC: "2.0", 157 | ID: &id, 158 | }, 159 | Result: items, 160 | } 161 | 162 | return response 163 | } 164 | 165 | func LineRange(line, start, end int) lsp.Range { 166 | return lsp.Range{ 167 | Start: lsp.Position{ 168 | Line: line, 169 | Character: start, 170 | }, 171 | End: lsp.Position{ 172 | Line: line, 173 | Character: end, 174 | }, 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module educationalsp 2 | 3 | go 1.21.0 4 | -------------------------------------------------------------------------------- /lsp/initialize.go: -------------------------------------------------------------------------------- 1 | package lsp 2 | 3 | type InitializeRequest struct { 4 | Request 5 | Params InitializeRequestParams `json:"params"` 6 | } 7 | 8 | type InitializeRequestParams struct { 9 | ClientInfo *ClientInfo `json:"clientInfo"` 10 | // ... there's tons more that goes here 11 | } 12 | 13 | type ClientInfo struct { 14 | Name string `json:"name"` 15 | Version string `json:"version"` 16 | } 17 | 18 | type InitializeResponse struct { 19 | Response 20 | Result InitializeResult `json:"result"` 21 | } 22 | 23 | type InitializeResult struct { 24 | Capabilities ServerCapabilities `json:"capabilities"` 25 | ServerInfo ServerInfo `json:"serverInfo"` 26 | } 27 | 28 | type ServerCapabilities struct { 29 | TextDocumentSync int `json:"textDocumentSync"` 30 | 31 | HoverProvider bool `json:"hoverProvider"` 32 | DefinitionProvider bool `json:"definitionProvider"` 33 | CodeActionProvider bool `json:"codeActionProvider"` 34 | CompletionProvider map[string]any `json:"completionProvider"` 35 | } 36 | 37 | type ServerInfo struct { 38 | Name string `json:"name"` 39 | Version string `json:"version"` 40 | } 41 | 42 | func NewInitializeResponse(id int) InitializeResponse { 43 | return InitializeResponse{ 44 | Response: Response{ 45 | RPC: "2.0", 46 | ID: &id, 47 | }, 48 | Result: InitializeResult{ 49 | Capabilities: ServerCapabilities{ 50 | TextDocumentSync: 1, 51 | HoverProvider: true, 52 | DefinitionProvider: true, 53 | CodeActionProvider: true, 54 | CompletionProvider: map[string]any{}, 55 | }, 56 | ServerInfo: ServerInfo{ 57 | Name: "educationalsp", 58 | Version: "0.0.0.0.0.0-beta1.final", 59 | }, 60 | }, 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /lsp/message.go: -------------------------------------------------------------------------------- 1 | package lsp 2 | 3 | type Request struct { 4 | RPC string `json:"jsonrpc"` 5 | ID int `json:"id"` 6 | Method string `json:"method"` 7 | 8 | // We will just specify the type of the params in all the Request types 9 | // Params ... 10 | } 11 | 12 | type Response struct { 13 | RPC string `json:"jsonrpc"` 14 | ID *int `json:"id,omitempty"` 15 | 16 | // Result 17 | // Error 18 | } 19 | 20 | type Notification struct { 21 | RPC string `json:"jsonrpc"` 22 | Method string `json:"method"` 23 | } 24 | -------------------------------------------------------------------------------- /lsp/textdocument.go: -------------------------------------------------------------------------------- 1 | package lsp 2 | 3 | type TextDocumentItem struct { 4 | /** 5 | * The text document's URI. 6 | */ 7 | URI string `json:"uri"` 8 | 9 | /** 10 | * The text document's language identifier. 11 | */ 12 | LanguageID string `json:"languageId"` 13 | 14 | /** 15 | * The version number of this document (it will increase after each 16 | * change, including undo/redo). 17 | */ 18 | Version int `json:"version"` 19 | 20 | /** 21 | * The content of the opened text document. 22 | */ 23 | Text string `json:"text"` 24 | } 25 | 26 | type TextDocumentIdentifier struct { 27 | URI string `json:"uri"` 28 | } 29 | 30 | type VersionTextDocumentIdentifier struct { 31 | TextDocumentIdentifier 32 | Version int `json:"version"` 33 | } 34 | 35 | type TextDocumentPositionParams struct { 36 | TextDocument TextDocumentIdentifier `json:"textDocument"` 37 | Position Position `json:"position"` 38 | } 39 | 40 | type Position struct { 41 | Line int `json:"line"` 42 | Character int `json:"character"` 43 | } 44 | 45 | type Location struct { 46 | URI string `json:"uri"` 47 | Range Range `json:"range"` 48 | } 49 | 50 | type Range struct { 51 | Start Position `json:"start"` 52 | End Position `json:"end"` 53 | } 54 | type WorkspaceEdit struct { 55 | Changes map[string][]TextEdit `json:"changes"` 56 | } 57 | 58 | type TextEdit struct { 59 | Range Range `json:"range"` 60 | NewText string `json:"newText"` 61 | } 62 | -------------------------------------------------------------------------------- /lsp/textdocument_codeaction.go: -------------------------------------------------------------------------------- 1 | package lsp 2 | 3 | type CodeActionRequest struct { 4 | Request 5 | Params TextDocumentCodeActionParams `json:"params"` 6 | } 7 | 8 | type TextDocumentCodeActionParams struct { 9 | TextDocument TextDocumentIdentifier `json:"textDocument"` 10 | Range Range `json:"range"` 11 | Context CodeActionContext `json:"context"` 12 | } 13 | 14 | type TextDocumentCodeActionResponse struct { 15 | Response 16 | Result []CodeAction `json:"result"` 17 | } 18 | 19 | type CodeActionContext struct { 20 | // Add fields for CodeActionContext as needed 21 | } 22 | 23 | type CodeAction struct { 24 | Title string `json:"title"` 25 | Edit *WorkspaceEdit `json:"edit,omitempty"` 26 | Command *Command `json:"command,omitempty"` 27 | } 28 | 29 | type Command struct { 30 | Title string `json:"title"` 31 | Command string `json:"command"` 32 | Arguments []interface{} `json:"arguments,omitempty"` 33 | } 34 | -------------------------------------------------------------------------------- /lsp/textdocument_completion.go: -------------------------------------------------------------------------------- 1 | package lsp 2 | 3 | type CompletionRequest struct { 4 | Request 5 | Params CompletionParams `json:"params"` 6 | } 7 | 8 | type CompletionParams struct { 9 | TextDocumentPositionParams 10 | } 11 | 12 | type CompletionResponse struct { 13 | Response 14 | Result []CompletionItem `json:"result"` 15 | } 16 | 17 | type CompletionItem struct { 18 | Label string `json:"label"` 19 | Detail string `json:"detail"` 20 | Documentation string `json:"documentation"` 21 | } 22 | -------------------------------------------------------------------------------- /lsp/textdocument_definition.go: -------------------------------------------------------------------------------- 1 | package lsp 2 | 3 | type DefinitionRequest struct { 4 | Request 5 | Params DefinitionParams `json:"params"` 6 | } 7 | 8 | type DefinitionParams struct { 9 | TextDocumentPositionParams 10 | } 11 | 12 | type DefinitionResponse struct { 13 | Response 14 | Result Location `json:"result"` 15 | } 16 | -------------------------------------------------------------------------------- /lsp/textdocument_diagnostics.go: -------------------------------------------------------------------------------- 1 | package lsp 2 | 3 | type PublishDiagnosticsNotification struct { 4 | Notification 5 | Params PublishDiagnosticsParams `json:"params"` 6 | } 7 | 8 | type PublishDiagnosticsParams struct { 9 | URI string `json:"uri"` 10 | Diagnostics []Diagnostic `json:"diagnostics"` 11 | } 12 | 13 | type Diagnostic struct { 14 | Range Range `json:"range"` 15 | Severity int `json:"severity"` 16 | Source string `json:"source"` 17 | Message string `json:"message"` 18 | } 19 | -------------------------------------------------------------------------------- /lsp/textdocument_didchange.go: -------------------------------------------------------------------------------- 1 | package lsp 2 | 3 | type TextDocumentDidChangeNotification struct { 4 | Notification 5 | Params DidChangeTextDocumentParams `json:"params"` 6 | } 7 | 8 | type DidChangeTextDocumentParams struct { 9 | TextDocument VersionTextDocumentIdentifier `json:"textDocument"` 10 | ContentChanges []TextDocumentContentChangeEvent `json:"contentChanges"` 11 | } 12 | 13 | /** 14 | * An event describing a change to a text document. If only a text is provided 15 | * it is considered to be the full content of the document. 16 | */ 17 | type TextDocumentContentChangeEvent struct { 18 | // The new text of the whole document. 19 | Text string `json:"text"` 20 | } 21 | -------------------------------------------------------------------------------- /lsp/textdocument_didopen.go: -------------------------------------------------------------------------------- 1 | package lsp 2 | 3 | type DidOpenTextDocumentNotification struct { 4 | Notification 5 | Params DidOpenTextDocumentParams `json:"params"` 6 | } 7 | 8 | type DidOpenTextDocumentParams struct { 9 | TextDocument TextDocumentItem `json:"textDocument"` 10 | } 11 | -------------------------------------------------------------------------------- /lsp/textdocument_hover.go: -------------------------------------------------------------------------------- 1 | package lsp 2 | 3 | type HoverRequest struct { 4 | Request 5 | Params HoverParams `json:"params"` 6 | } 7 | 8 | type HoverParams struct { 9 | TextDocumentPositionParams 10 | } 11 | 12 | type HoverResponse struct { 13 | Response 14 | Result HoverResult `json:"result"` 15 | } 16 | 17 | type HoverResult struct { 18 | Contents string `json:"contents"` 19 | } 20 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "educationalsp/analysis" 6 | "educationalsp/lsp" 7 | "educationalsp/rpc" 8 | "encoding/json" 9 | "io" 10 | "log" 11 | "os" 12 | ) 13 | 14 | func main() { 15 | logger := getLogger("/home/tjdevries/git/educationalsp/log.txt") 16 | logger.Println("Hey, I started!") 17 | 18 | scanner := bufio.NewScanner(os.Stdin) 19 | scanner.Split(rpc.Split) 20 | 21 | state := analysis.NewState() 22 | writer := os.Stdout 23 | 24 | for scanner.Scan() { 25 | msg := scanner.Bytes() 26 | method, contents, err := rpc.DecodeMessage(msg) 27 | if err != nil { 28 | logger.Printf("Got an error: %s", err) 29 | continue 30 | } 31 | 32 | handleMessage(logger, writer, state, method, contents) 33 | } 34 | } 35 | 36 | func handleMessage(logger *log.Logger, writer io.Writer, state analysis.State, method string, contents []byte) { 37 | logger.Printf("Received msg with method: %s", method) 38 | 39 | switch method { 40 | case "initialize": 41 | var request lsp.InitializeRequest 42 | if err := json.Unmarshal(contents, &request); err != nil { 43 | logger.Printf("Hey, we couldn't parse this: %s", err) 44 | } 45 | 46 | logger.Printf("Connected to: %s %s", 47 | request.Params.ClientInfo.Name, 48 | request.Params.ClientInfo.Version) 49 | 50 | // hey... let's reply! 51 | msg := lsp.NewInitializeResponse(request.ID) 52 | writeResponse(writer, msg) 53 | 54 | logger.Print("Sent the reply") 55 | case "textDocument/didOpen": 56 | var request lsp.DidOpenTextDocumentNotification 57 | if err := json.Unmarshal(contents, &request); err != nil { 58 | logger.Printf("textDocument/didOpen: %s", err) 59 | return 60 | } 61 | 62 | logger.Printf("Opened: %s", request.Params.TextDocument.URI) 63 | diagnostics := state.OpenDocument(request.Params.TextDocument.URI, request.Params.TextDocument.Text) 64 | writeResponse(writer, lsp.PublishDiagnosticsNotification{ 65 | Notification: lsp.Notification{ 66 | RPC: "2.0", 67 | Method: "textDocument/publishDiagnostics", 68 | }, 69 | Params: lsp.PublishDiagnosticsParams{ 70 | URI: request.Params.TextDocument.URI, 71 | Diagnostics: diagnostics, 72 | }, 73 | }) 74 | case "textDocument/didChange": 75 | var request lsp.TextDocumentDidChangeNotification 76 | if err := json.Unmarshal(contents, &request); err != nil { 77 | logger.Printf("textDocument/didChange: %s", err) 78 | return 79 | } 80 | 81 | logger.Printf("Changed: %s", request.Params.TextDocument.URI) 82 | for _, change := range request.Params.ContentChanges { 83 | diagnostics := state.UpdateDocument(request.Params.TextDocument.URI, change.Text) 84 | writeResponse(writer, lsp.PublishDiagnosticsNotification{ 85 | Notification: lsp.Notification{ 86 | RPC: "2.0", 87 | Method: "textDocument/publishDiagnostics", 88 | }, 89 | Params: lsp.PublishDiagnosticsParams{ 90 | URI: request.Params.TextDocument.URI, 91 | Diagnostics: diagnostics, 92 | }, 93 | }) 94 | } 95 | case "textDocument/hover": 96 | var request lsp.HoverRequest 97 | if err := json.Unmarshal(contents, &request); err != nil { 98 | logger.Printf("textDocument/hover: %s", err) 99 | return 100 | } 101 | 102 | // Create a response 103 | response := state.Hover(request.ID, request.Params.TextDocument.URI, request.Params.Position) 104 | 105 | // Write it back 106 | writeResponse(writer, response) 107 | case "textDocument/definition": 108 | var request lsp.DefinitionRequest 109 | if err := json.Unmarshal(contents, &request); err != nil { 110 | logger.Printf("textDocument/definition: %s", err) 111 | return 112 | } 113 | 114 | // Create a response 115 | response := state.Definition(request.ID, request.Params.TextDocument.URI, request.Params.Position) 116 | 117 | // Write it back 118 | writeResponse(writer, response) 119 | case "textDocument/codeAction": 120 | var request lsp.CodeActionRequest 121 | if err := json.Unmarshal(contents, &request); err != nil { 122 | logger.Printf("textDocument/codeAction: %s", err) 123 | return 124 | } 125 | 126 | // Create a response 127 | response := state.TextDocumentCodeAction(request.ID, request.Params.TextDocument.URI) 128 | 129 | // Write it back 130 | writeResponse(writer, response) 131 | case "textDocument/completion": 132 | var request lsp.CompletionRequest 133 | if err := json.Unmarshal(contents, &request); err != nil { 134 | logger.Printf("textDocument/codeAction: %s", err) 135 | return 136 | } 137 | 138 | // Create a response 139 | response := state.TextDocumentCompletion(request.ID, request.Params.TextDocument.URI) 140 | 141 | // Write it back 142 | writeResponse(writer, response) 143 | } 144 | } 145 | 146 | func writeResponse(writer io.Writer, msg any) { 147 | reply := rpc.EncodeMessage(msg) 148 | writer.Write([]byte(reply)) 149 | 150 | } 151 | 152 | func getLogger(filename string) *log.Logger { 153 | logfile, err := os.OpenFile(filename, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0666) 154 | if err != nil { 155 | panic("hey, you didnt give me a good file") 156 | } 157 | 158 | return log.New(logfile, "[educationalsp]", log.Ldate|log.Ltime|log.Lshortfile) 159 | } 160 | -------------------------------------------------------------------------------- /rpc/rpc.go: -------------------------------------------------------------------------------- 1 | package rpc 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "strconv" 9 | ) 10 | 11 | func EncodeMessage(msg any) string { 12 | content, err := json.Marshal(msg) 13 | if err != nil { 14 | panic(err) 15 | } 16 | 17 | return fmt.Sprintf("Content-Length: %d\r\n\r\n%s", len(content), content) 18 | } 19 | 20 | type BaseMessage struct { 21 | Method string `json:"method"` 22 | } 23 | 24 | func DecodeMessage(msg []byte) (string, []byte, error) { 25 | header, content, found := bytes.Cut(msg, []byte{'\r', '\n', '\r', '\n'}) 26 | if !found { 27 | return "", nil, errors.New("Did not find separator") 28 | } 29 | 30 | // Content-Length: 31 | contentLengthBytes := header[len("Content-Length: "):] 32 | contentLength, err := strconv.Atoi(string(contentLengthBytes)) 33 | if err != nil { 34 | return "", nil, err 35 | } 36 | 37 | var baseMessage BaseMessage 38 | if err := json.Unmarshal(content[:contentLength], &baseMessage); err != nil { 39 | return "", nil, err 40 | } 41 | 42 | return baseMessage.Method, content[:contentLength], nil 43 | } 44 | 45 | // type SplitFunc func(data []byte, atEOF bool) (advance int, token []byte, err error) 46 | func Split(data []byte, _ bool) (advance int, token []byte, err error) { 47 | header, content, found := bytes.Cut(data, []byte{'\r', '\n', '\r', '\n'}) 48 | if !found { 49 | return 0, nil, nil 50 | } 51 | 52 | // Content-Length: 53 | contentLengthBytes := header[len("Content-Length: "):] 54 | contentLength, err := strconv.Atoi(string(contentLengthBytes)) 55 | if err != nil { 56 | return 0, nil, err 57 | } 58 | 59 | if len(content) < contentLength { 60 | return 0, nil, nil 61 | } 62 | 63 | totalLength := len(header) + 4 + contentLength 64 | return totalLength, data[:totalLength], nil 65 | } 66 | -------------------------------------------------------------------------------- /rpc/rpc_test.go: -------------------------------------------------------------------------------- 1 | package rpc_test 2 | 3 | import ( 4 | "educationalsp/rpc" 5 | "testing" 6 | ) 7 | 8 | type EncodingExample struct { 9 | Testing bool 10 | } 11 | 12 | func TestEncode(t *testing.T) { 13 | expected := "Content-Length: 16\r\n\r\n{\"Testing\":true}" 14 | actual := rpc.EncodeMessage(EncodingExample{Testing: true}) 15 | if expected != actual { 16 | t.Fatalf("Expected: %s, Actual: %s", expected, actual) 17 | } 18 | } 19 | 20 | func TestDecode(t *testing.T) { 21 | incomingMessage := "Content-Length: 15\r\n\r\n{\"Method\":\"hi\"}" 22 | method, content, err := rpc.DecodeMessage([]byte(incomingMessage)) 23 | contentLength := len(content) 24 | if err != nil { 25 | t.Fatal(err) 26 | } 27 | 28 | if contentLength != 15 { 29 | t.Fatalf("Expected: 16, Got: %d", contentLength) 30 | } 31 | 32 | if method != "hi" { 33 | t.Fatalf("Expected: 'hi', Got: %s", method) 34 | } 35 | } 36 | 37 | --------------------------------------------------------------------------------