diff --git a/Makefile b/Makefile index b2d8e296..c85f089a 100644 --- a/Makefile +++ b/Makefile @@ -43,6 +43,7 @@ SRCS = src/main.c \ src/lexer/token.c \ src/analysis/typecheck.c \ src/lsp/json_rpc.c \ + src/lsp/json_rpc_error.c \ src/lsp/lsp_main.c \ src/lsp/lsp_analysis.c \ src/lsp/lsp_index.c \ @@ -192,6 +193,7 @@ test: $(TARGET) $(PLUGINS) ./tests/run_tests.sh ./tests/run_codegen_tests.sh ./tests/run_example_transpile.sh + ./tests/run_lsp_tests.sh # Build with alternative compilers zig: diff --git a/src/lsp/json_rpc.c b/src/lsp/json_rpc.c index ffb5d1fd..d22fc66a 100644 --- a/src/lsp/json_rpc.c +++ b/src/lsp/json_rpc.c @@ -1,11 +1,13 @@ #include "json_rpc.h" #include "cJSON.h" #include "lsp_project.h" +#include #include #include #include +#include -void lsp_check_file(const char *uri, const char *src, int id); +void lsp_check_file(const char *uri, const char *src); void lsp_goto_definition(const char *uri, int line, int col, int id); void lsp_hover(const char *uri, int line, int col, int id); void lsp_completion(const char *uri, int line, int col, int id); @@ -48,6 +50,203 @@ static void get_params(cJSON *root, char **uri, int *line, int *col) } } +void send_lsp_message_json(const cJSON *json) +{ + char *body; + size_t len; + + assert(json); + + body = cJSON_PrintUnformatted(json); + + len = strlen(body); + + fprintf(stdout, "Content-Length: %zu\r\n\r\n", len); + fwrite(body, 1, len, stdout); + fflush(stdout); + + free(body); +} + +cJSON *create_response(const cJSON *id_item, + const cJSON *result, + const cJSON *error) +{ + cJSON *res; + + assert(id_item || (error && !id_item)); + assert((result && !error) || (!result && error)); + + res = cJSON_CreateObject(); + if (!res) { + return NULL; + } + + cJSON_AddStringToObject(res, "jsonrpc", "2.0"); + + cJSON_AddItemToObject(res, "id", cJSON_Duplicate(id_item, 1)); + + if (result) + { + cJSON_AddItemToObject(res, "result", cJSON_Duplicate(result, 1)); + } + else { + cJSON_AddItemToObject(res, "error", cJSON_Duplicate(error, 1)); + } + + return res; +} + +void handle_initialize(const cJSON *json, const cJSON *id_item) +{ + cJSON *params = cJSON_GetObjectItem(json, "params"); + char *root = NULL; + if (params) + { + cJSON *rp = cJSON_GetObjectItem(params, "rootPath"); + if (rp && rp->valuestring) + { + root = strdup(rp->valuestring); + } + else + { + cJSON *ru = cJSON_GetObjectItem(params, "rootUri"); + if (ru && ru->valuestring) + { + root = strdup(ru->valuestring); + } + } + } + + if (root && strncmp(root, "file://", 7) == 0) + { + char *clean = strdup(root + 7); + free(root); + root = clean; + } + + lsp_project_init(root ? root : "."); + if (root) + { + free(root); + } + + cJSON *result = cJSON_CreateObject(); + + cJSON *serverInfo = cJSON_AddObjectToObject(result, "serverInfo"); + cJSON_AddStringToObject(serverInfo, "name", "ZenC LS"); + cJSON_AddStringToObject(serverInfo, "version", "1.0.0"); + + // server capabilities + cJSON *caps = cJSON_AddObjectToObject(result, "capabilities"); + + cJSON *sync = cJSON_AddObjectToObject(caps, "textDocumentSync"); + cJSON_AddBoolToObject(sync, "openClose", true); + cJSON_AddNumberToObject(sync, "change", 1); + + cJSON_AddBoolToObject(caps, "definitionProvider", true); + cJSON_AddBoolToObject(caps, "hoverProvider", true); + cJSON_AddBoolToObject(caps, "referencesProvider", true); + cJSON_AddBoolToObject(caps, "documentSymbolProvider", true); + + cJSON *sig = cJSON_AddObjectToObject(caps, "signatureHelpProvider"); + cJSON *sig_trig = cJSON_AddArrayToObject(sig, "triggerCharacters"); + cJSON_AddItemToArray(sig_trig, cJSON_CreateString("(")); + + cJSON *comp = cJSON_AddObjectToObject(caps, "completionProvider"); + cJSON *comp_trig = cJSON_AddArrayToObject(comp, "triggerCharacters"); + cJSON_AddItemToArray(comp_trig, cJSON_CreateString(".")); + + cJSON *response = create_response(id_item, result, NULL); + send_lsp_message_json(response); + + cJSON_Delete(result); + cJSON_Delete(response); +} + +void handle_shutdown(cJSON *id_item) +{ + assert(id_item); + + fprintf(stderr, "zls: shutdown received\n"); + + // lsp_state.shutdown = 1; + // TODO: after shutdown every request except exit gonna send JSONRPC_INVALID_REQUEST + + cJSON *result = cJSON_CreateNull(); + + cJSON *response = create_response(id_item, result, NULL); + + send_lsp_message_json(response); + + cJSON_Delete(result); + cJSON_Delete(response); +} + +void handle_exit(void) +{ + fprintf(stderr, "zls: exit received\n"); + // TODO: add the lsp clean here + + // TODO: exit 0 if shutdown is call before exit else exit 1 + exit(0); +} + +void handle_did_open(const cJSON *json) +{ + cJSON *params = cJSON_GetObjectItem(json, "params"); + if (!params) + { + return; + } + cJSON *doc = cJSON_GetObjectItem(params, "textDocument"); + if (!doc) + { + return; + } + cJSON *uri = cJSON_GetObjectItem(doc, "uri"); + if (!uri || !cJSON_IsString(uri)) + { + return; + } + cJSON *text = cJSON_GetObjectItem(doc, "text"); + if (!text || !cJSON_IsString(text)) + { + return; + } + lsp_check_file(uri->valuestring, text->valuestring); +} + +void handle_did_change(const cJSON *json) +{ + cJSON *params = cJSON_GetObjectItem(json, "params"); + if (!params) + { + return; + } + cJSON *changes = cJSON_GetObjectItem(params, "contentChanges"); + if (!changes) + { + return; + } + cJSON *doc = cJSON_GetObjectItem(params, "textDocument"); + if (!doc) + { + return; + } + cJSON *uri = cJSON_GetObjectItem(doc, "uri"); + if (!uri || !cJSON_IsString(uri)) + { + return; + } + cJSON *text = cJSON_GetObjectItem(changes, "text"); + if (!text || !cJSON_IsString(text)) + { + return; + } + lsp_check_file(uri->valuestring, text->valuestring); +} + void handle_request(const char *json_str) { cJSON *json = cJSON_Parse(json_str); @@ -58,14 +257,22 @@ void handle_request(const char *json_str) int id = 0; cJSON *id_item = cJSON_GetObjectItem(json, "id"); - if (id_item) - { - id = id_item->valueint; - } + if (id_item && !(cJSON_IsNumber(id_item) || cJSON_IsString(id_item))) + { + invalid_request(NULL); + cJSON_Delete(json); + return; + } + if (id_item) + { + // FIXME: not always int but can be string too + id = id_item->valueint; + } cJSON *method_item = cJSON_GetObjectItem(json, "method"); - if (!method_item || !method_item->valuestring) + if (!method_item || !cJSON_IsString(method_item)) { + invalid_request(id_item); cJSON_Delete(json); return; } @@ -73,79 +280,16 @@ void handle_request(const char *json_str) if (strcmp(method, "initialize") == 0) { - cJSON *params = cJSON_GetObjectItem(json, "params"); - char *root = NULL; - if (params) - { - cJSON *rp = cJSON_GetObjectItem(params, "rootPath"); - if (rp && rp->valuestring) - { - root = strdup(rp->valuestring); - } - else - { - cJSON *ru = cJSON_GetObjectItem(params, "rootUri"); - if (ru && ru->valuestring) - { - root = strdup(ru->valuestring); - } - } - } - - if (root && strncmp(root, "file://", 7) == 0) - { - char *clean = strdup(root + 7); - free(root); - root = clean; - } - - lsp_project_init(root ? root : "."); - if (root) - { - free(root); - } - - const char *response = - "{\"jsonrpc\":\"2.0\",\"id\":0,\"result\":{" - "\"serverInfo\":{\"name\":\"ZenC LS\",\"version\": \"1.0.0\"}," - "\"capabilities\":{\"textDocumentSync\":{\"openClose\":true,\"change\":1}," - "\"definitionProvider\":true,\"hoverProvider\":true," - "\"referencesProvider\":true,\"documentSymbolProvider\":true," - "\"signatureHelpProvider\":{\"triggerCharacters\":[\"(\"]}," - "\"completionProvider\":{" - "\"triggerCharacters\":[\".\"]}}}}"; - fprintf(stdout, "Content-Length: %zu\r\n\r\n%s", strlen(response), response); - fflush(stdout); - } - else if (strcmp(method, "textDocument/didOpen") == 0 || - strcmp(method, "textDocument/didChange") == 0) - { - cJSON *params = cJSON_GetObjectItem(json, "params"); - if (params) - { - cJSON *doc = cJSON_GetObjectItem(params, "textDocument"); - if (doc) - { - cJSON *uri = cJSON_GetObjectItem(doc, "uri"); - cJSON *text = cJSON_GetObjectItem(doc, "text"); - // For didChange, text is inside contentChanges - if (!text && strcmp(method, "textDocument/didChange") == 0) - { - cJSON *changes = cJSON_GetObjectItem(params, "contentChanges"); - if (changes && cJSON_GetArraySize(changes) > 0) - { - cJSON *change = cJSON_GetArrayItem(changes, 0); - text = cJSON_GetObjectItem(change, "text"); - } - } - - if (uri && uri->valuestring && text && text->valuestring) - { - lsp_check_file(uri->valuestring, text->valuestring, id); - } - } - } + handle_initialize(json, id_item); } + else if (strcmp(method, "textDocument/didOpen") == 0) + { + handle_did_open(json); + } + else if (strcmp(method, "textDocument/didChange") == 0) + { + handle_did_change(json); + } else if (strcmp(method, "textDocument/definition") == 0) { char *uri = NULL; @@ -212,6 +356,18 @@ void handle_request(const char *json_str) free(uri); } } + else if (strcmp(method, "shutdown") == 0) + { + handle_shutdown(id_item); + } + else if (strcmp(method, "exit") == 0) + { + handle_exit(); + } + else + { + method_not_found(id_item); + } cJSON_Delete(json); } diff --git a/src/lsp/json_rpc.h b/src/lsp/json_rpc.h index 0a5c2821..f88e3d0f 100644 --- a/src/lsp/json_rpc.h +++ b/src/lsp/json_rpc.h @@ -2,6 +2,18 @@ #ifndef JSON_RPC_H #define JSON_RPC_H +#include "cJSON.h" + +typedef enum jsonrpc_error_t +{ + JSONRPC_PARSE_ERROR = -32700, + JSONRPC_INVALID_REQUEST = -32600, + JSONRPC_METHOD_NOT_FOUND= -32601, + JSONRPC_INVALID_PARAMS = -32602, + JSONRPC_INTERNAL_ERROR = -32603, +} jsonrpc_error_t; + + /** * @brief Handle a raw JSON-RPC request string. * @@ -12,4 +24,17 @@ */ void handle_request(const char *json_str); +void method_not_found(const cJSON *id_item); +void internal_error(const cJSON *id_item); +void parse_error(const cJSON *id_item); +void invalid_params(const cJSON *id_item); +void invalid_request(const cJSON *id_item); + +cJSON *create_response(const cJSON *id_item, + const cJSON *result, + const cJSON *error); + +void send_lsp_message_json(const cJSON *json); + + #endif diff --git a/src/lsp/json_rpc_error.c b/src/lsp/json_rpc_error.c new file mode 100644 index 00000000..196d082c --- /dev/null +++ b/src/lsp/json_rpc_error.c @@ -0,0 +1,81 @@ + +#include "json_rpc.h" +#include + +cJSON *create_error(const jsonrpc_error_t err_code, + const char *err_msg) +{ + cJSON *res; + + assert(err_msg); + + res = cJSON_CreateObject(); + cJSON_AddNumberToObject(res, "code", err_code); + cJSON_AddStringToObject(res, "message", err_msg); + + return res; +} + +void send_error(const cJSON *id_item, const jsonrpc_error_t err_code, const char *err_msg) +{ + cJSON *err = create_error(err_code, err_msg); + cJSON *res = create_response(id_item, NULL, err); + + send_lsp_message_json(res); + + cJSON_Delete(err); + cJSON_Delete(res); +} + +const char *jsonrpc_error_message(jsonrpc_error_t code) +{ + switch (code) { + case JSONRPC_PARSE_ERROR: return "Parse error"; + case JSONRPC_INVALID_REQUEST: return "Invalid Request"; + case JSONRPC_METHOD_NOT_FOUND: return "Method not found"; + case JSONRPC_INVALID_PARAMS: return "Invalid params"; + case JSONRPC_INTERNAL_ERROR: return "Internal error"; + default: return "Unknown error"; + } +} + + +void method_not_found(const cJSON *id_item) +{ + const jsonrpc_error_t err_code = JSONRPC_METHOD_NOT_FOUND; + const char *msg = jsonrpc_error_message(err_code); + + send_error(id_item, err_code, msg); +} + +void parse_error(const cJSON *id_item) +{ + const jsonrpc_error_t err_code = JSONRPC_PARSE_ERROR; + const char *msg = jsonrpc_error_message(err_code); + + send_error(id_item, err_code, msg); +} + +void invalid_request(const cJSON *id_item) +{ + const jsonrpc_error_t err_code = JSONRPC_INVALID_REQUEST; + const char *msg = jsonrpc_error_message(err_code); + + send_error(id_item, err_code, msg); +} + +void invalid_params(const cJSON *id_item) +{ + const jsonrpc_error_t err_code = JSONRPC_INVALID_PARAMS; + const char *msg = jsonrpc_error_message(err_code); + + send_error(id_item, err_code, msg); +} + +void internal_error(const cJSON *id_item) +{ + const jsonrpc_error_t err_code = JSONRPC_INTERNAL_ERROR; + const char *msg = jsonrpc_error_message(err_code); + + send_error(id_item, err_code, msg); +} \ No newline at end of file diff --git a/src/lsp/lsp_analysis.c b/src/lsp/lsp_analysis.c index 0367d930..dbe74653 100644 --- a/src/lsp/lsp_analysis.c +++ b/src/lsp/lsp_analysis.c @@ -56,7 +56,7 @@ void lsp_on_error(void *data, Token t, const char *msg) } } -void lsp_check_file(const char *uri, const char *json_src, int id) +void lsp_check_file(const char *uri, const char *json_src) { if (!g_project) { @@ -93,7 +93,6 @@ void lsp_check_file(const char *uri, const char *json_src, int id) // Construct JSON Response (publishDiagnostics) cJSON *root = cJSON_CreateObject(); cJSON_AddStringToObject(root, "jsonrpc", "2.0"); - cJSON_AddNumberToObject(root, "id", id); cJSON_AddStringToObject(root, "method", "textDocument/publishDiagnostics"); cJSON *params = cJSON_CreateObject(); diff --git a/tests/run_lsp_tests.sh b/tests/run_lsp_tests.sh new file mode 100755 index 00000000..fda7e8c5 --- /dev/null +++ b/tests/run_lsp_tests.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# LSP Protocol Compliance Test Suite +# Usage: ./run_lsp_tests.sh + +ZC="./zc" +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' + +PASSED=0 +FAILED=0 +FAILED_TESTS=() + +send_lsp_message() { + local json="$1" + local len + len=$(printf '%s' "$json" | wc -c) + + response=$(printf "Content-Length: %d\r\n\r\n%s" "$len" "$json" | "$ZC" lsp 2>/dev/null) + printf '%s' "$response" +} + +check_response() { + local response="$1" + local name="$2" + local expected_id="$3" + local expect_error="${4:-0}" + local is_notification="${5:-0}" + + local status="FAIL" + local detail="" + + if [[ $is_notification -eq 1 ]]; then + if [[ -z "$response" ]]; then + status="PASS" + else + detail="→ got response but notification should be silent" + fi + elif [[ -z "$response" ]]; then + if [[ $expect_error -eq 1 ]]; then + status="PASS" + detail="→ no response (correct for invalid request)" + else + detail="→ no response received" + fi + else + id=$(echo "$response" | grep -oP '"id":\s*(\d+|"[a-zA-Z0-9]+?"|\[[^\]]+\]|true|false|null)' | head -1 | cut -d: -f2- | tr -d ' "') + + has_error=$(echo "$response" | grep -c '"error"' || true) + has_result=$(echo "$response" | grep -c '"result"' || true) + + if [[ $expect_error -eq 1 ]]; then + [[ $has_error -gt 0 ]] && status="PASS" || detail="→ expected error but none found" + else + if [[ "$id" = "$expected_id" ]]; then + if [[ $has_result -gt 0 ]]; then + status="PASS" + elif [[ $has_error -gt 0 ]]; then + detail="→ got error instead of result" + else + detail="→ missing both result & error" + fi + else + detail="→ id mismatch (got '$id', expected '$expected_id')" + fi + fi + fi + + if [[ $status = "PASS" ]]; then + echo -e "Testing $name... PASS" + ((PASSED++)) + else + echo -e "Testing $name... FAIL$" + [[ -n "$detail" ]] && echo " $detail" + ((FAILED++)) + FAILED_TESTS+=("$name") + fi +} + +# ──────────────────────────────────────────────── +echo "Running LSP Compliance Tests..." +echo + +# ALL tests + +response=$(send_lsp_message '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"rootUri":"file:///tmp"}}') +check_response "$response" "initialize (id=1)" "1" 0 0 + +response=$(send_lsp_message '{"jsonrpc":"2.0","id":"abc","method":"initialize","params":{"rootUri":"file:///tmp"}}') +check_response "$response" "initialize (id=\"abc\")" "abc" 0 0 + +response=$(send_lsp_message '{"jsonrpc":"2.0","id":true,"method":"initialize","params":{"rootUri":"file:///tmp"}}') +check_response "$response" "initialize (invalid id=true)" "true" 1 0 + +response=$(send_lsp_message '{"jsonrpc":"2.0","id":[1,2],"method":"initialize","params":{"rootUri":"file:///tmp"}}') +check_response "$response" "initialize (invalid id=array)" "[1,2]" 1 0 + +response=$(send_lsp_message '{"jsonrpc":"2.0","method":"initialize","params":{"rootUri":"file:///tmp"}}') +check_response "$response" "initialize as notification (no id)" "" 1 0 + +response=$(send_lsp_message '{"jsonrpc":"2.0","id":2,"method":"shutdown","params":null}') +check_response "$response" "shutdown (id=2)" "2" 0 0 + +response=$(send_lsp_message '{"jsonrpc":"2.0","method":"exit"}') +check_response "$response" "exit notification" "" 0 1 + +response=$(send_lsp_message '{"jsonrpc":"2.0","id":99,"method":"unknownMethod","params":{}}') +check_response "$response" "unknown method" "99" 1 0 + +echo +echo "----------------------------------------" +echo "Summary:" +echo "-> Passed: $PASSED" +echo "-> Failed: $FAILED" +echo "----------------------------------------" \ No newline at end of file