From 281e0b8a920c369ff566935b4767ad888d42c27e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adolfo=20Ochagav=C3=ADa?= Date: Fri, 20 Feb 2026 17:11:18 -0300 Subject: [PATCH 1/4] Improve error message of `EmbedBlockResult.Failure` We were accidentally converting the whole exception to a string, so the final error was something along the lines of "IllegalArgumentException: ". This commit corrects that. --- kson-lib/src/commonMain/kotlin/org/kson/Kson.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kson-lib/src/commonMain/kotlin/org/kson/Kson.kt b/kson-lib/src/commonMain/kotlin/org/kson/Kson.kt index 58f20f13..00d0cfcc 100644 --- a/kson-lib/src/commonMain/kotlin/org/kson/Kson.kt +++ b/kson-lib/src/commonMain/kotlin/org/kson/Kson.kt @@ -216,7 +216,7 @@ class EmbedRule private constructor( return try { EmbedRuleResult.Success(EmbedRule(pathPattern, tag)) } catch (e: IllegalArgumentException) { - EmbedRuleResult.Failure(e.toString()) + EmbedRuleResult.Failure(e.message ?: "") } } } From c0c075ff75551d28d7cc107839881b59ce0c1d04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adolfo=20Ochagav=C3=ADa?= Date: Fri, 20 Feb 2026 17:20:52 -0300 Subject: [PATCH 2/4] Update to latest krossover --- kson-lib/build.gradle.kts | 2 +- lib-python/src/kson/__init__.py | 6 ++- lib-rust/kson/src/generated/mod.rs | 64 +++++++++++++++++++++++++++++- 3 files changed, 67 insertions(+), 5 deletions(-) diff --git a/kson-lib/build.gradle.kts b/kson-lib/build.gradle.kts index 26d76bcf..6db44e4b 100644 --- a/kson-lib/build.gradle.kts +++ b/kson-lib/build.gradle.kts @@ -10,7 +10,7 @@ plugins { kotlin("multiplatform") id("com.vanniktech.maven.publish") version "0.30.0" id("org.jetbrains.dokka") version "2.0.0" - id("nl.ochagavia.krossover") version "1.0.5" + id("nl.ochagavia.krossover") version "1.0.6" } repositories { diff --git a/lib-python/src/kson/__init__.py b/lib-python/src/kson/__init__.py index 0f46f3c2..a3c32d29 100644 --- a/lib-python/src/kson/__init__.py +++ b/lib-python/src/kson/__init__.py @@ -2017,9 +2017,9 @@ def from_path_pattern( if path_pattern is None: raise ValueError("`path_pattern` cannot be None") - jni_ref = _access_static_field(b"org/kson/EmbedRule", b"INSTANCE", b"Lorg/kson/EmbedRule;") + jni_ref = _access_static_field(b"org/kson/EmbedRule", b"Companion", b"Lorg/kson/EmbedRule$Companion;") result = _call_method( - b"org/kson/EmbedRule", + b"org/kson/EmbedRule$Companion", jni_ref, b"fromPathPattern", b"(Ljava/lang/String;Ljava/lang/String;)Lorg/kson/EmbedRuleResult;", @@ -2117,6 +2117,8 @@ class _IndentType_Tabs(IndentType): _jni_ref: Any + def __init__(self): + self._jni_ref = _access_static_field(b"org/kson/IndentType$Tabs", b"INSTANCE", b"Lorg/kson/IndentType$Tabs;") def __eq__(self, other): return _call_method(b"java/lang/Object", self._jni_ref, b"equals", b"(Ljava/lang/Object;)Z", "BooleanMethod", [other._jni_ref]) diff --git a/lib-rust/kson/src/generated/mod.rs b/lib-rust/kson/src/generated/mod.rs index 50c1b7df..dabaec17 100644 --- a/lib-rust/kson/src/generated/mod.rs +++ b/lib-rust/kson/src/generated/mod.rs @@ -266,6 +266,62 @@ impl std::hash::Hash for Position { } + +#[derive(Clone)] +pub struct Companion { + kotlin_ptr: KotlinPtr, +} + +impl FromKotlinObject for Companion { + fn from_kotlin_object(obj: self::sys::jobject) -> Self { + let (env, _detach_guard) = util::attach_thread_to_java_vm(); + let kotlin_ptr = util::to_gc_global_ref(env, obj); + Self { kotlin_ptr } + } +} + +impl ToKotlinObject for Companion { + fn to_kotlin_object(&self) -> KotlinPtr { + self.kotlin_ptr.clone() + } +} + +impl AsKotlinObject for Companion { + fn as_kotlin_object(&self) -> self::sys::jobject { + self.kotlin_ptr.inner.inner + } +} + +impl Companion { +} + + +impl Companion { +} + +impl std::fmt::Debug for Companion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let obj = self.to_kotlin_object(); + write!(f, "{}", util::call_to_string(c"org/kson/EmbedRule$Companion", &obj)) + } +} + +impl Eq for Companion {} +impl PartialEq for Companion { + fn eq(&self, other: &Companion) -> bool { + util::equals(self.to_kotlin_object(), other.to_kotlin_object()) + } +} +impl std::hash::Hash for Companion { + fn hash(&self, state: &mut H) + where + H: std::hash::Hasher, + { + util::apply_hash_code(self.to_kotlin_object(), state) + } +} + + /// Represents a message logged during Kson processing #[derive(Clone)] pub struct Message { @@ -3581,7 +3637,7 @@ impl EmbedRule { path_pattern: &str, tag: Option<&str>, ) -> EmbedRuleResult { - let self_ptr = util::access_static_field(c"org/kson/EmbedRule", c"INSTANCE", c"Lorg/kson/EmbedRule;"); + let self_ptr = util::access_static_field(c"org/kson/EmbedRule", c"Companion", c"Lorg/kson/EmbedRule$Companion;"); let self_obj = self_ptr.as_kotlin_object(); let path_pattern_ptr = path_pattern.to_kotlin_object(); let path_pattern = path_pattern_ptr.as_kotlin_object(); @@ -3591,7 +3647,7 @@ impl EmbedRule { let (_, _detach_guard) = util::attach_thread_to_java_vm(); let result = call_jvm_function!( util, - c"org/kson/EmbedRule", + c"org/kson/EmbedRule$Companion", c"fromPathPattern", c"(Ljava/lang/String;Ljava/lang/String;)Lorg/kson/EmbedRuleResult;", CallObjectMethod, @@ -3758,6 +3814,10 @@ pub mod indent_type { } impl Tabs { + pub fn new() -> Self { + let kotlin_ptr = util::access_static_field(c"org/kson/IndentType$Tabs", c"INSTANCE", c"Lorg/kson/IndentType$Tabs;"); + Self { kotlin_ptr } + } } From f242e2fd14a1abd31eb377c74b53739f6cb9ae1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adolfo=20Ochagav=C3=ADa?= Date: Thu, 19 Feb 2026 23:34:23 -0300 Subject: [PATCH 3/4] Run `kson-lib` tests against `lib-python` Since the `kson-lib` tests are written in Kotlin, we run them against Python in an indirect way: - We LLM-generated a JSON Schema based on the `kson-lib` API (with minimal manual tweaks). - We LLM-generated a clone of `kson-lib`'s `Kson.kt` that internally sends POST requests to a server based on the JSON Schema (with minimal manual tweaks). We called it `kson-lib-http` and wrote a descriptive readme for it. - We LLM-generated a Python server that receives requests and uses the bindings to handle them (with minimal manual tweaks). - We created a `:lib-python:kson-lib-tests` project responsible for actually running the `kson-lib` tests against the Python server. This setup is a bit convoluted, and I'm not sure I'd choose it for a different project. However, next to it being useful for testing, it doubles as an experiment to see how the KSON API could look like if we were to expose it through a text-based API (currently JSON, in the future... KSON!) --- kson-lib-http/build.gradle.kts | 26 + kson-lib-http/kson-api-schema.json | 595 ++++++++++++++++++ kson-lib-http/readme.md | 24 + .../src/commonMain/kotlin/org/kson/Kson.kt | 432 +++++++++++++ lib-python/kson-lib-tests/build.gradle.kts | 54 ++ .../src/jvmTest/kotlin/ServerExtension.kt | 56 ++ .../org.junit.jupiter.api.extension.Extension | 1 + lib-python/tests/api_server.py | 280 +++++++++ settings.gradle.kts | 2 + 9 files changed, 1470 insertions(+) create mode 100644 kson-lib-http/build.gradle.kts create mode 100644 kson-lib-http/kson-api-schema.json create mode 100644 kson-lib-http/readme.md create mode 100644 kson-lib-http/src/commonMain/kotlin/org/kson/Kson.kt create mode 100644 lib-python/kson-lib-tests/build.gradle.kts create mode 100644 lib-python/kson-lib-tests/src/jvmTest/kotlin/ServerExtension.kt create mode 100644 lib-python/kson-lib-tests/src/jvmTest/resources/META-INF/services/org.junit.jupiter.api.extension.Extension create mode 100644 lib-python/tests/api_server.py diff --git a/kson-lib-http/build.gradle.kts b/kson-lib-http/build.gradle.kts new file mode 100644 index 00000000..c5e3f5b9 --- /dev/null +++ b/kson-lib-http/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + kotlin("multiplatform") + kotlin("plugin.serialization") +} + +repositories { + mavenCentral() +} + +val ktorVersion = "3.4.0" + +kotlin { + jvm { + } + + sourceSets { + commonMain { + dependencies { + implementation("io.ktor:ktor-client-core:$ktorVersion") + implementation("io.ktor:ktor-client-cio:$ktorVersion") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1") + } + } + } +} + diff --git a/kson-lib-http/kson-api-schema.json b/kson-lib-http/kson-api-schema.json new file mode 100644 index 00000000..02cddad2 --- /dev/null +++ b/kson-lib-http/kson-api-schema.json @@ -0,0 +1,595 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://kson.org/api/schema", + "title": "KSON API Schema", + "description": "Schema for the KSON public API, supporting format, toJson, toYaml, analyze, parseSchema, validate, and validateEmbedRule commands.", + + "$defs": { + "Position": { + "type": "object", + "description": "A zero-based line/column position in a document.", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "description": "The line number (0-based)" + }, + "column": { + "type": "integer", + "minimum": 0, + "description": "The column number (0-based)" + } + }, + "required": ["line", "column"], + "additionalProperties": false + }, + + "Message": { + "type": "object", + "description": "A message logged during KSON processing.", + "properties": { + "message": { + "type": "string" + }, + "severity": { + "$ref": "#/$defs/MessageSeverity" + }, + "start": { + "$ref": "#/$defs/Position" + }, + "end": { + "$ref": "#/$defs/Position" + } + }, + "required": ["message", "severity", "start", "end"], + "additionalProperties": false + }, + + "MessageSeverity": { + "type": "string", + "enum": ["ERROR", "WARNING"] + }, + + "IndentType": { + "oneOf": [ + { + "type": "object", + "description": "Use spaces for indentation.", + "properties": { + "type": { "const": "spaces" }, + "size": { + "type": "integer", + "minimum": 1, + "default": 2, + "description": "Number of spaces per indent level" + } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "description": "Use tabs for indentation.", + "properties": { + "type": { "const": "tabs" } + }, + "required": ["type"], + "additionalProperties": false + } + ] + }, + + "FormattingStyle": { + "type": "string", + "enum": ["PLAIN", "DELIMITED", "COMPACT", "CLASSIC"] + }, + + "EmbedRule": { + "type": "object", + "description": "A rule for formatting string values at specific paths as embed blocks.", + "properties": { + "pathPattern": { + "type": "string", + "description": "A JsonPointerGlob pattern (e.g., \"/scripts/*\", \"/queries/**\")" + }, + "tag": { + "type": ["string", "null"], + "description": "Optional embed tag (e.g., \"yaml\", \"sql\", \"bash\")" + } + }, + "required": ["pathPattern"], + "additionalProperties": false + }, + + "FormatOptions": { + "type": "object", + "description": "Options for formatting KSON output.", + "properties": { + "indentType": { + "$ref": "#/$defs/IndentType" + }, + "formattingStyle": { + "$ref": "#/$defs/FormattingStyle" + }, + "embedBlockRules": { + "type": "array", + "items": { "$ref": "#/$defs/EmbedRule" } + } + }, + "additionalProperties": false + }, + + "TokenType": { + "type": "string", + "enum": [ + "CURLY_BRACE_L", + "CURLY_BRACE_R", + "SQUARE_BRACKET_L", + "SQUARE_BRACKET_R", + "ANGLE_BRACKET_L", + "ANGLE_BRACKET_R", + "COLON", + "DOT", + "END_DASH", + "COMMA", + "COMMENT", + "EMBED_OPEN_DELIM", + "EMBED_CLOSE_DELIM", + "EMBED_TAG", + "EMBED_PREAMBLE_NEWLINE", + "EMBED_CONTENT", + "FALSE", + "UNQUOTED_STRING", + "ILLEGAL_CHAR", + "LIST_DASH", + "NULL", + "NUMBER", + "STRING_OPEN_QUOTE", + "STRING_CLOSE_QUOTE", + "STRING_CONTENT", + "TRUE", + "WHITESPACE", + "EOF" + ] + }, + + "Token": { + "type": "object", + "description": "A token produced by the lexing phase.", + "properties": { + "tokenType": { "$ref": "#/$defs/TokenType" }, + "text": { "type": "string" }, + "start": { "$ref": "#/$defs/Position" }, + "end": { "$ref": "#/$defs/Position" } + }, + "required": ["tokenType", "text", "start", "end"], + "additionalProperties": false + }, + + "KsonValue": { + "oneOf": [ + { "$ref": "#/$defs/KsonObject" }, + { "$ref": "#/$defs/KsonArray" }, + { "$ref": "#/$defs/KsonString" }, + { "$ref": "#/$defs/KsonInteger" }, + { "$ref": "#/$defs/KsonDecimal" }, + { "$ref": "#/$defs/KsonBoolean" }, + { "$ref": "#/$defs/KsonNull" }, + { "$ref": "#/$defs/KsonEmbed" } + ] + }, + + "KsonObject": { + "type": "object", + "properties": { + "type": { "const": "OBJECT" }, + "properties": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/KsonValue" }, + "description": "Key-value pairs of the object" + }, + "propertyKeys": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/KsonString" }, + "description": "Property key metadata (including positions)" + }, + "start": { "$ref": "#/$defs/Position" }, + "end": { "$ref": "#/$defs/Position" } + }, + "required": ["type", "properties", "propertyKeys", "start", "end"], + "additionalProperties": false + }, + + "KsonArray": { + "type": "object", + "properties": { + "type": { "const": "ARRAY" }, + "elements": { + "type": "array", + "items": { "$ref": "#/$defs/KsonValue" } + }, + "start": { "$ref": "#/$defs/Position" }, + "end": { "$ref": "#/$defs/Position" } + }, + "required": ["type", "elements", "start", "end"], + "additionalProperties": false + }, + + "KsonString": { + "type": "object", + "properties": { + "type": { "const": "STRING" }, + "value": { "type": "string" }, + "start": { "$ref": "#/$defs/Position" }, + "end": { "$ref": "#/$defs/Position" } + }, + "required": ["type", "value", "start", "end"], + "additionalProperties": false + }, + + "KsonInteger": { + "type": "object", + "properties": { + "type": { "const": "INTEGER" }, + "value": { "type": "integer" }, + "start": { "$ref": "#/$defs/Position" }, + "end": { "$ref": "#/$defs/Position" } + }, + "required": ["type", "value", "start", "end"], + "additionalProperties": false + }, + + "KsonDecimal": { + "type": "object", + "properties": { + "type": { "const": "DECIMAL" }, + "value": { "type": "number" }, + "start": { "$ref": "#/$defs/Position" }, + "end": { "$ref": "#/$defs/Position" } + }, + "required": ["type", "value", "start", "end"], + "additionalProperties": false + }, + + "KsonBoolean": { + "type": "object", + "properties": { + "type": { "const": "BOOLEAN" }, + "value": { "type": "boolean" }, + "start": { "$ref": "#/$defs/Position" }, + "end": { "$ref": "#/$defs/Position" } + }, + "required": ["type", "value", "start", "end"], + "additionalProperties": false + }, + + "KsonNull": { + "type": "object", + "properties": { + "type": { "const": "NULL" }, + "start": { "$ref": "#/$defs/Position" }, + "end": { "$ref": "#/$defs/Position" } + }, + "required": ["type", "start", "end"], + "additionalProperties": false + }, + + "KsonEmbed": { + "type": "object", + "properties": { + "type": { "const": "EMBED" }, + "tag": { + "type": ["string", "null"], + "description": "Optional embed tag" + }, + "content": { "type": "string" }, + "start": { "$ref": "#/$defs/Position" }, + "end": { "$ref": "#/$defs/Position" } + }, + "required": ["type", "content", "start", "end"], + "additionalProperties": false + }, + + "FormatCommand": { + "type": "object", + "description": "Formats KSON source with the specified formatting options.", + "properties": { + "command": { "const": "format" }, + "kson": { + "type": "string", + "description": "The KSON source to format" + }, + "formatOptions": { + "$ref": "#/$defs/FormatOptions" + } + }, + "required": ["command", "kson"], + "additionalProperties": false + }, + + "ToJsonCommand": { + "type": "object", + "description": "Converts KSON to JSON.", + "properties": { + "command": { "const": "toJson" }, + "kson": { + "type": "string", + "description": "The KSON source to convert" + }, + "retainEmbedTags": { + "type": "boolean", + "default": true, + "description": "Whether to retain embed tags in the JSON output" + } + }, + "required": ["command", "kson"], + "additionalProperties": false + }, + + "ToYamlCommand": { + "type": "object", + "description": "Converts KSON to YAML, preserving comments.", + "properties": { + "command": { "const": "toYaml" }, + "kson": { + "type": "string", + "description": "The KSON source to convert" + }, + "retainEmbedTags": { + "type": "boolean", + "default": true, + "description": "Whether to retain embed tags in the YAML output" + } + }, + "required": ["command", "kson"], + "additionalProperties": false + }, + + "AnalyzeCommand": { + "type": "object", + "description": "Statically analyze KSON and return messages, tokens, and parsed value.", + "properties": { + "command": { "const": "analyze" }, + "kson": { + "type": "string", + "description": "The KSON source to analyze" + }, + "filepath": { + "type": ["string", "null"], + "description": "Optional filepath of the document being analyzed" + } + }, + "required": ["command", "kson"], + "additionalProperties": false + }, + + "ParseSchemaCommand": { + "type": "object", + "description": "Parses a KSON schema definition.", + "properties": { + "command": { "const": "parseSchema" }, + "schemaKson": { + "type": "string", + "description": "The KSON source defining a JSON Schema" + } + }, + "required": ["command", "schemaKson"], + "additionalProperties": false + }, + + "ValidateCommand": { + "type": "object", + "description": "Parses a schema and validates a KSON document against it in a single step.", + "properties": { + "command": { "const": "validate" }, + "schemaKson": { + "type": "string", + "description": "The KSON source defining a JSON Schema" + }, + "kson": { + "type": "string", + "description": "The KSON source to validate against the schema" + }, + "filepath": { + "type": ["string", "null"], + "description": "Optional filepath of the document being validated" + } + }, + "required": ["command", "schemaKson", "kson"], + "additionalProperties": false + }, + + "ValidateEmbedRuleCommand": { + "type": "object", + "description": "Validates that the given EmbedRule has a valid JsonPointerGlob pathPattern.", + "properties": { + "command": { "const": "validateEmbedRule" }, + "embedBlockRule": { + "$ref": "#/$defs/EmbedRule", + "description": "The embed rule to validate" + } + }, + "required": ["command", "embedBlockRule"], + "additionalProperties": false + }, + + "FormatResult": { + "type": "object", + "description": "Result of the format command.", + "properties": { + "command": { "const": "format" }, + "success": { "const": true }, + "output": { + "type": "string", + "description": "The formatted KSON source" + } + }, + "required": ["command", "success", "output"], + "additionalProperties": false + }, + + "TranspileSuccessResult": { + "type": "object", + "properties": { + "command": { + "type": "string", + "enum": ["toJson", "toYaml"] + }, + "success": { "const": true }, + "output": { + "type": "string", + "description": "The transpiled output (JSON or YAML)" + } + }, + "required": ["command", "success", "output"], + "additionalProperties": false + }, + + "TranspileFailureResult": { + "type": "object", + "properties": { + "command": { + "type": "string", + "enum": ["toJson", "toYaml"] + }, + "success": { "const": false }, + "errors": { + "type": "array", + "items": { "$ref": "#/$defs/Message" } + } + }, + "required": ["command", "success", "errors"], + "additionalProperties": false + }, + + "AnalyzeResult": { + "type": "object", + "description": "Result of the analyze command.", + "properties": { + "command": { "const": "analyze" }, + "errors": { + "type": "array", + "items": { "$ref": "#/$defs/Message" } + }, + "tokens": { + "type": "array", + "items": { "$ref": "#/$defs/Token" } + }, + "ksonValue": { + "oneOf": [ + { "$ref": "#/$defs/KsonValue" }, + { "type": "null" } + ] + } + }, + "required": ["command", "errors", "tokens", "ksonValue"], + "additionalProperties": false + }, + + "ParseSchemaSuccessResult": { + "type": "object", + "properties": { + "command": { "const": "parseSchema" }, + "success": { "const": true } + }, + "required": ["command", "success"], + "additionalProperties": false + }, + + "ParseSchemaFailureResult": { + "type": "object", + "properties": { + "command": { "const": "parseSchema" }, + "success": { "const": false }, + "errors": { + "type": "array", + "items": { "$ref": "#/$defs/Message" } + } + }, + "required": ["command", "success", "errors"], + "additionalProperties": false + }, + + "ValidateResult": { + "type": "object", + "description": "Result of the validate command.", + "properties": { + "command": { "const": "validate" }, + "success": { + "type": "boolean", + "description": "True if both schema parsing and validation succeeded with no errors" + }, + "errors": { + "type": "array", + "items": { "$ref": "#/$defs/Message" }, + "description": "Schema parsing errors or validation errors" + } + }, + "required": ["command", "success", "errors"], + "additionalProperties": false + }, + + "EmbedRuleSuccessResult": { + "type": "object", + "description": "Successful result of the validateEmbedRule command.", + "properties": { + "command": { "const": "validateEmbedRule" }, + "success": { "const": true } + }, + "required": ["command", "success"], + "additionalProperties": false + }, + + "EmbedRuleFailureResult": { + "type": "object", + "description": "Failed result of the validateEmbedRule command.", + "properties": { + "command": { "const": "validateEmbedRule" }, + "success": { "const": false }, + "error": { + "type": "string", + "description": "Error message for the invalid pathPattern" + } + }, + "required": ["command", "success", "error"], + "additionalProperties": false + } + }, + + "type": "object", + "properties": { + "request": { + "description": "A KSON API command request.", + "oneOf": [ + { "$ref": "#/$defs/FormatCommand" }, + { "$ref": "#/$defs/ToJsonCommand" }, + { "$ref": "#/$defs/ToYamlCommand" }, + { "$ref": "#/$defs/AnalyzeCommand" }, + { "$ref": "#/$defs/ParseSchemaCommand" }, + { "$ref": "#/$defs/ValidateCommand" }, + { "$ref": "#/$defs/ValidateEmbedRuleCommand" } + ], + "discriminator": { + "propertyName": "command" + } + }, + "response": { + "description": "A KSON API command response.", + "oneOf": [ + { "$ref": "#/$defs/FormatResult" }, + { "$ref": "#/$defs/TranspileSuccessResult" }, + { "$ref": "#/$defs/TranspileFailureResult" }, + { "$ref": "#/$defs/AnalyzeResult" }, + { "$ref": "#/$defs/ParseSchemaSuccessResult" }, + { "$ref": "#/$defs/ParseSchemaFailureResult" }, + { "$ref": "#/$defs/ValidateResult" }, + { "$ref": "#/$defs/EmbedRuleSuccessResult" }, + { "$ref": "#/$defs/EmbedRuleFailureResult" } + ], + "discriminator": { + "propertyName": "command" + } + } + }, + "additionalProperties": false +} diff --git a/kson-lib-http/readme.md b/kson-lib-http/readme.md new file mode 100644 index 00000000..c39a244b --- /dev/null +++ b/kson-lib-http/readme.md @@ -0,0 +1,24 @@ +# kson-lib-http + +A reimplementation of KSON's public API (see [kson-lib](../kson-lib)) based on +HTTP requests to a server that respects the [kson-api-schema](./kson-api-schema.json). +This code is meant to facilitate testing, so users of KSON should ignore it +and rely on `kson-lib` for all their KSON needs. We might implement a +user-facing, HTTP-based KSON API in the future. + +> [!WARNING] +> The JSON Schema is highly experimental, possibly inaccurate in some places, and +> subject to change without notice + +### Testing KSON implementations + +If you have an implementation of the KSON public API that you would like to +test, all you need to do is create an HTTP server that handles KSON requests +according to the [schema](./kson-api-schema.json) and internally dispatches +them to your actual KSON implementation. This sounds complicated, but a good +LLM can easily one-shot server creation based on the schema and your +implementation's source code. + +With that in place, you then need to instruct Gradle to run the `kson-lib` +tests against your server. For reference, see the [test project](../lib-python/kson-lib-tests/) +we use to run the `kson-lib` tests against our Python bindings. diff --git a/kson-lib-http/src/commonMain/kotlin/org/kson/Kson.kt b/kson-lib-http/src/commonMain/kotlin/org/kson/Kson.kt new file mode 100644 index 00000000..365b2f44 --- /dev/null +++ b/kson-lib-http/src/commonMain/kotlin/org/kson/Kson.kt @@ -0,0 +1,432 @@ +package org.kson + +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.* + +object Kson { + private val client = HttpClient(CIO) + private val json = Json { ignoreUnknownKeys = true } + private var baseUrl = "http://localhost:8080" + + fun setPort(port: Int) { + baseUrl = "http://localhost:$port" + } + + fun format(kson: String, formatOptions: FormatOptions = FormatOptions()): String { + val request = buildJsonObject { + put("command", "format") + put("kson", kson) + put("formatOptions", formatOptions.toJson()) + } + val response = post(request) + return response["output"]!!.jsonPrimitive.content + } + + + fun toJson(kson: String, options: TranspileOptions.Json = TranspileOptions.Json()): Result { + val request = buildJsonObject { + put("command", "toJson") + put("kson", kson) + put("retainEmbedTags", options.retainEmbedTags) + } + return parseTranspileResult(post(request)) + } + + + fun toYaml(kson: String, options: TranspileOptions.Yaml = TranspileOptions.Yaml()): Result { + val request = buildJsonObject { + put("command", "toYaml") + put("kson", kson) + put("retainEmbedTags", options.retainEmbedTags) + } + return parseTranspileResult(post(request)) + } + + + fun analyze(kson: String, filepath: String? = null): Analysis { + val request = buildJsonObject { + put("command", "analyze") + put("kson", kson) + filepath?.let { put("filepath", it) } + } + val response = post(request) + val errors = parseMessages(response["errors"]!!.jsonArray) + val tokens = parseTokens(response["tokens"]!!.jsonArray) + val ksonValue = response["ksonValue"]?.let { + if (it is JsonNull) null else parseKsonValue(it.jsonObject) + } + return Analysis(errors, tokens, ksonValue) + } + + + fun parseSchema(schemaKson: String): SchemaResult { + val request = buildJsonObject { + put("command", "parseSchema") + put("schemaKson", schemaKson) + } + val response = post(request) + val success = response["success"]!!.jsonPrimitive.boolean + return if (success) { + SchemaResult.Success(SchemaValidator(schemaKson)) + } else { + SchemaResult.Failure(parseMessages(response["errors"]!!.jsonArray)) + } + } + + internal fun post(body: JsonObject): JsonObject = runBlocking { + val response = client.post(baseUrl) { + contentType(ContentType.Application.Json) + setBody(body.toString()) + } + json.parseToJsonElement(response.bodyAsText()).jsonObject + } + + private fun parseTranspileResult(response: JsonObject): Result { + val success = response["success"]!!.jsonPrimitive.boolean + return if (success) { + Result.Success(response["output"]!!.jsonPrimitive.content) + } else { + Result.Failure(parseMessages(response["errors"]!!.jsonArray)) + } + } +} + + +sealed class Result { + class Success(val output: String) : Result() + class Failure(val errors: List) : Result() +} + + +sealed class SchemaResult { + class Success(val schemaValidator: SchemaValidator) : SchemaResult() + class Failure(val errors: List) : SchemaResult() +} + +class SchemaValidator internal constructor(private val schemaKson: String) { + + fun validate(kson: String, filepath: String? = null): List { + val request = buildJsonObject { + put("command", "validate") + put("schemaKson", schemaKson) + put("kson", kson) + filepath?.let { put("filepath", it) } + } + val response = Kson.post(request) + return parseMessages(response["errors"]!!.jsonArray) + } +} + + +class EmbedRule private constructor( + val pathPattern: String, + val tag: String? = null +) { + companion object { + fun fromPathPattern(pathPattern: String, tag: String? = null): EmbedRuleResult { + val request = buildJsonObject { + put("command", "validateEmbedRule") + put("embedBlockRule", buildJsonObject { + put("pathPattern", pathPattern) + tag?.let { put("tag", it) } + }) + } + val response = Kson.post(request) + val success = response["success"]?.jsonPrimitive?.boolean == true + return if (success) { + EmbedRuleResult.Success(EmbedRule(pathPattern, tag)) + } else { + val error = response["error"]!!.jsonPrimitive.content + EmbedRuleResult.Failure(error) + } + } + } +} + +sealed class EmbedRuleResult { + data class Success(val embedRule: EmbedRule) : EmbedRuleResult() + data class Failure(val message: String) : EmbedRuleResult() +} + + +class FormatOptions( + val indentType: IndentType = IndentType.Spaces(2), + val formattingStyle: FormattingStyle = FormattingStyle.PLAIN, + val embedBlockRules: List = emptyList() +) { + internal fun toJson(): JsonObject = buildJsonObject { + put("indentType", when (indentType) { + is IndentType.Spaces -> buildJsonObject { + put("type", "spaces") + put("size", indentType.size) + } + is IndentType.Tabs -> buildJsonObject { + put("type", "tabs") + } + }) + put("formattingStyle", formattingStyle.name) + put("embedBlockRules", buildJsonArray { + for (rule in embedBlockRules) { + add(buildJsonObject { + put("pathPattern", rule.pathPattern) + rule.tag?.let { put("tag", it) } + }) + } + }) + } +} + + +sealed class TranspileOptions { + abstract val retainEmbedTags: Boolean + + + class Json( + override val retainEmbedTags: Boolean = true + ) : TranspileOptions() + + + class Yaml( + override val retainEmbedTags: Boolean = true + ) : TranspileOptions() +} + + +enum class FormattingStyle { + PLAIN, + DELIMITED, + COMPACT, + CLASSIC +} + +sealed class IndentType { + + class Spaces(val size: Int = 2) : IndentType() + + + data object Tabs : IndentType() +} + + +class Analysis internal constructor( + val errors: List, + val tokens: List, + val ksonValue: KsonValue? +) + + +class Token internal constructor( + val tokenType: TokenType, + val text: String, + val start: Position, + val end: Position) + +enum class TokenType { + CURLY_BRACE_L, + CURLY_BRACE_R, + SQUARE_BRACKET_L, + SQUARE_BRACKET_R, + ANGLE_BRACKET_L, + ANGLE_BRACKET_R, + COLON, + DOT, + END_DASH, + COMMA, + COMMENT, + EMBED_OPEN_DELIM, + EMBED_CLOSE_DELIM, + EMBED_TAG, + EMBED_PREAMBLE_NEWLINE, + EMBED_CONTENT, + FALSE, + UNQUOTED_STRING, + ILLEGAL_CHAR, + LIST_DASH, + NULL, + NUMBER, + STRING_OPEN_QUOTE, + STRING_CLOSE_QUOTE, + STRING_CONTENT, + TRUE, + WHITESPACE, + EOF +} + + +class Message internal constructor(val message: String, val severity: MessageSeverity, val start: Position, val end: Position) + + +enum class MessageSeverity { + ERROR, + WARNING, +} + + +class Position internal constructor(val line: Int, val column: Int) + + +enum class KsonValueType { + OBJECT, + ARRAY, + STRING, + INTEGER, + DECIMAL, + BOOLEAN, + NULL, + EMBED +} + +sealed class KsonValue(val start: Position, val end: Position) { + + abstract val type: KsonValueType + + + @ConsistentCopyVisibility + data class KsonObject internal constructor( + val properties: Map, + val propertyKeys: Map, + private val internalStart: Position, + private val internalEnd: Position + ) : KsonValue(internalStart, internalEnd) { + override val type = KsonValueType.OBJECT + } + + + class KsonArray internal constructor( + val elements: List, + internalStart: Position, + internalEnd: Position + ) : KsonValue(internalStart, internalEnd) { + override val type = KsonValueType.ARRAY + } + + + class KsonString internal constructor( + val value: String, + internalStart: Position, + internalEnd: Position + ) : KsonValue(internalStart, internalEnd) { + override val type = KsonValueType.STRING + } + + + sealed class KsonNumber(start: Position, end: Position) : KsonValue(start, end) { + + class Integer internal constructor( + val value: Int, + val internalStart: Position, + val internalEnd: Position + ) : KsonNumber(internalStart, internalEnd) { + override val type = KsonValueType.INTEGER + } + + class Decimal internal constructor( + val value: Double, + internalStart: Position, + internalEnd: Position + ) : KsonNumber(internalStart, internalEnd) { + override val type = KsonValueType.DECIMAL + } + } + + + class KsonBoolean internal constructor( + val value: Boolean, + internalStart: Position, + internalEnd: Position + ) : KsonValue(internalStart, internalEnd) { + override val type = KsonValueType.BOOLEAN + } + + + class KsonNull internal constructor( + internalStart: Position, + internalEnd: Position + ) : KsonValue(internalStart, internalEnd) { + override val type = KsonValueType.NULL + } + + + class KsonEmbed internal constructor( + val tag: String?, + val content: String, + internalStart: Position, + internalEnd: Position + ) : KsonValue(internalStart, internalEnd) { + override val type = KsonValueType.EMBED + } +} + +// --- JSON response parsing helpers --- + +internal fun parsePosition(obj: JsonObject): Position { + return Position( + line = obj["line"]!!.jsonPrimitive.int, + column = obj["column"]!!.jsonPrimitive.int + ) +} + +internal fun parseMessages(array: JsonArray): List { + return array.map { element -> + val obj = element.jsonObject + Message( + message = obj["message"]!!.jsonPrimitive.content, + severity = MessageSeverity.valueOf(obj["severity"]!!.jsonPrimitive.content), + start = parsePosition(obj["start"]!!.jsonObject), + end = parsePosition(obj["end"]!!.jsonObject) + ) + } +} + +internal fun parseTokens(array: JsonArray): List { + return array.map { element -> + val obj = element.jsonObject + Token( + tokenType = TokenType.valueOf(obj["tokenType"]!!.jsonPrimitive.content), + text = obj["text"]!!.jsonPrimitive.content, + start = parsePosition(obj["start"]!!.jsonObject), + end = parsePosition(obj["end"]!!.jsonObject) + ) + } +} + +internal fun parseKsonValue(obj: JsonObject): KsonValue { + val start = parsePosition(obj["start"]!!.jsonObject) + val end = parsePosition(obj["end"]!!.jsonObject) + + return when (val type = obj["type"]!!.jsonPrimitive.content) { + "OBJECT" -> { + val properties = obj["properties"]!!.jsonObject.mapValues { (_, v) -> parseKsonValue(v.jsonObject) } + val propertyKeys = obj["propertyKeys"]!!.jsonObject.mapValues { (_, v) -> + val keyObj = v.jsonObject + KsonValue.KsonString( + value = keyObj["value"]!!.jsonPrimitive.content, + internalStart = parsePosition(keyObj["start"]!!.jsonObject), + internalEnd = parsePosition(keyObj["end"]!!.jsonObject) + ) + } + KsonValue.KsonObject(properties, propertyKeys, start, end) + } + "ARRAY" -> { + val elements = obj["elements"]!!.jsonArray.map { parseKsonValue(it.jsonObject) } + KsonValue.KsonArray(elements, start, end) + } + "STRING" -> KsonValue.KsonString(obj["value"]!!.jsonPrimitive.content, start, end) + "INTEGER" -> KsonValue.KsonNumber.Integer(obj["value"]!!.jsonPrimitive.int, start, end) + "DECIMAL" -> KsonValue.KsonNumber.Decimal(obj["value"]!!.jsonPrimitive.double, start, end) + "BOOLEAN" -> KsonValue.KsonBoolean(obj["value"]!!.jsonPrimitive.boolean, start, end) + "NULL" -> KsonValue.KsonNull(start, end) + "EMBED" -> KsonValue.KsonEmbed( + tag = obj["tag"]?.let { if (it is JsonNull) null else it.jsonPrimitive.content }, + content = obj["content"]!!.jsonPrimitive.content, + internalStart = start, + internalEnd = end + ) + else -> throw IllegalArgumentException("Unknown KsonValue type: $type") + } +} diff --git a/lib-python/kson-lib-tests/build.gradle.kts b/lib-python/kson-lib-tests/build.gradle.kts new file mode 100644 index 00000000..c4f82005 --- /dev/null +++ b/lib-python/kson-lib-tests/build.gradle.kts @@ -0,0 +1,54 @@ +plugins { + kotlin("multiplatform") +} + +repositories { + mavenCentral() +} + +val syncCommonTestSources by tasks.registering(Sync::class) { + from(project(":kson-lib").file("src/commonTest/kotlin")) + into(layout.buildDirectory.dir("commonTestSources")) +} + +val syncJvmTestSources by tasks.registering(Sync::class) { + from(project(":kson-lib").file("src/jvmTest/kotlin")) + into(layout.buildDirectory.dir("jvmTestSources")) +} + +tasks.withType { + dependsOn(":lib-python:build") + + systemProperty("libPythonDir", project(":lib-python").projectDir.absolutePath) + + useJUnitPlatform() + jvmArgs("-Djunit.jupiter.extensions.autodetection.enabled=true") +} + +kotlin { + jvm() + + sourceSets { + commonTest { + dependencies { + implementation(project(":kson-lib-http")) + implementation(kotlin("test")) + } + + kotlin { + srcDir(syncCommonTestSources) + } + } + jvmTest { + dependencies { + implementation("org.junit.jupiter:junit-jupiter-api:5.14.2") + runtimeOnly("org.junit.jupiter:junit-jupiter-engine:5.14.2") + } + + kotlin { + srcDir(syncJvmTestSources) + } + } + } +} + diff --git a/lib-python/kson-lib-tests/src/jvmTest/kotlin/ServerExtension.kt b/lib-python/kson-lib-tests/src/jvmTest/kotlin/ServerExtension.kt new file mode 100644 index 00000000..1ab56b14 --- /dev/null +++ b/lib-python/kson-lib-tests/src/jvmTest/kotlin/ServerExtension.kt @@ -0,0 +1,56 @@ +import org.junit.jupiter.api.extension.BeforeAllCallback +import org.junit.jupiter.api.extension.ExtensionContext +import org.kson.Kson +import java.io.File +import java.net.Socket + +/** + * A JUnit extension that makes sure the Python KSON server is running before all tests and gets + * shut down at the end + */ +class ServerExtension : BeforeAllCallback, AutoCloseable { + + companion object { + private var started = false + private var process: Process? = null + } + + override fun beforeAll(context: ExtensionContext) { + if (!started) { + started = true + + // Register for cleanup when the root context closes (after all tests) + context.root.getStore(ExtensionContext.Namespace.GLOBAL) + .put("serverExtension", this) + + val libPythonDir = File(System.getProperty("libPythonDir")) + val isWindows = System.getProperty("os.name").lowercase().contains("win") + val uvwCommand = if (isWindows) listOf("cmd", "/c", "uvw.bat") else listOf("./uvw") + val port = 8082 + + Kson.setPort(port) + + process = ProcessBuilder(uvwCommand + listOf("run", "python", "tests/api_server.py", port.toString())) + .directory(libPythonDir) + .redirectOutput(ProcessBuilder.Redirect.INHERIT) + .redirectErrorStream(true) + .start() + + // Wait for readiness + repeat(30) { + try { + Socket("localhost", port).close() + return + } catch (_: Exception) { + Thread.sleep(1000) + } + } + throw RuntimeException("Server did not start in time") + } + } + + override fun close() { + process?.destroy() + process?.waitFor() + } +} diff --git a/lib-python/kson-lib-tests/src/jvmTest/resources/META-INF/services/org.junit.jupiter.api.extension.Extension b/lib-python/kson-lib-tests/src/jvmTest/resources/META-INF/services/org.junit.jupiter.api.extension.Extension new file mode 100644 index 00000000..849fdf0a --- /dev/null +++ b/lib-python/kson-lib-tests/src/jvmTest/resources/META-INF/services/org.junit.jupiter.api.extension.Extension @@ -0,0 +1 @@ +ServerExtension diff --git a/lib-python/tests/api_server.py b/lib-python/tests/api_server.py new file mode 100644 index 00000000..38914f23 --- /dev/null +++ b/lib-python/tests/api_server.py @@ -0,0 +1,280 @@ +"""HTTP server exposing the KSON Python API, conforming to kson-api-schema.json.""" + +import json +import sys +from http.server import HTTPServer, BaseHTTPRequestHandler +from kson import ( + Kson, FormatOptions, IndentType, FormattingStyle, EmbedRule, EmbedRuleResult, + TranspileOptions, Result, SchemaResult, KsonValue, KsonValueType, + MessageSeverity, +) + + +def _serialize_position(pos): + return {"line": pos.line(), "column": pos.column()} + + +def _serialize_message(msg): + return { + "message": msg.message(), + "severity": msg.severity().name, + "start": _serialize_position(msg.start()), + "end": _serialize_position(msg.end()), + } + + +def _serialize_token(token): + return { + "tokenType": token.token_type().name, + "text": token.text(), + "start": _serialize_position(token.start()), + "end": _serialize_position(token.end()), + } + + +def _serialize_kson_value(value): + if value is None: + return None + + vtype = value.type() + + if vtype == KsonValueType.OBJECT: + props = value.properties() + prop_keys = value.property_keys() + return { + "type": "OBJECT", + "properties": {k: _serialize_kson_value(v) for k, v in props.items()}, + "propertyKeys": {k: _serialize_kson_value(v) for k, v in prop_keys.items()}, + "start": _serialize_position(value.start()), + "end": _serialize_position(value.end()), + } + elif vtype == KsonValueType.ARRAY: + return { + "type": "ARRAY", + "elements": [_serialize_kson_value(e) for e in value.elements()], + "start": _serialize_position(value.start()), + "end": _serialize_position(value.end()), + } + elif vtype == KsonValueType.STRING: + return { + "type": "STRING", + "value": value.value(), + "start": _serialize_position(value.start()), + "end": _serialize_position(value.end()), + } + elif vtype == KsonValueType.INTEGER: + return { + "type": "INTEGER", + "value": value.value(), + "start": _serialize_position(value.start()), + "end": _serialize_position(value.end()), + } + elif vtype == KsonValueType.DECIMAL: + return { + "type": "DECIMAL", + "value": value.value(), + "start": _serialize_position(value.start()), + "end": _serialize_position(value.end()), + } + elif vtype == KsonValueType.BOOLEAN: + return { + "type": "BOOLEAN", + "value": bool(value.value()), + "start": _serialize_position(value.start()), + "end": _serialize_position(value.end()), + } + elif vtype == KsonValueType.NULL: + return { + "type": "NULL", + "start": _serialize_position(value.start()), + "end": _serialize_position(value.end()), + } + elif vtype == KsonValueType.EMBED: + return { + "type": "EMBED", + "tag": value.tag(), + "content": value.content(), + "start": _serialize_position(value.start()), + "end": _serialize_position(value.end()), + } + + +def _parse_indent_type(obj): + if obj is None: + return IndentType.Spaces(2) + if obj["type"] == "tabs": + return IndentType.Tabs() + return IndentType.Spaces(obj.get("size", 2)) + + +def _parse_formatting_style(name): + if name is None: + return FormattingStyle.PLAIN + return FormattingStyle[name] + + +def _parse_embed_rule(obj): + result = EmbedRule.from_path_pattern(obj["pathPattern"], obj.get("tag")) + assert isinstance(result, EmbedRuleResult.Success) + return result.embed_rule() + + +def _parse_format_options(obj): + if obj is None: + return FormatOptions(IndentType.Spaces(2), FormattingStyle.PLAIN, []) + indent = _parse_indent_type(obj.get("indentType")) + style = _parse_formatting_style(obj.get("formattingStyle")) + rules = [_parse_embed_rule(r) for r in obj.get("embedBlockRules", [])] + return FormatOptions(indent, style, rules) + + +def handle_format(req): + options = _parse_format_options(req.get("formatOptions")) + output = Kson.format(req["kson"], options) + return {"command": "format", "success": True, "output": output} + + +def handle_to_json(req): + retain = req.get("retainEmbedTags", True) + result = Kson.to_json(req["kson"], TranspileOptions.Json(retain_embed_tags=retain)) + if isinstance(result, Result.Success): + return {"command": "toJson", "success": True, "output": result.output()} + else: + return { + "command": "toJson", + "success": False, + "errors": [_serialize_message(m) for m in result.errors()], + } + + +def handle_to_yaml(req): + retain = req.get("retainEmbedTags", True) + result = Kson.to_yaml(req["kson"], TranspileOptions.Yaml(retain_embed_tags=retain)) + if isinstance(result, Result.Success): + return {"command": "toYaml", "success": True, "output": result.output()} + else: + return { + "command": "toYaml", + "success": False, + "errors": [_serialize_message(m) for m in result.errors()], + } + + +def handle_analyze(req): + filepath = req.get("filepath") + analysis = Kson.analyze(req["kson"], filepath) + return { + "command": "analyze", + "errors": [_serialize_message(m) for m in analysis.errors()], + "tokens": [_serialize_token(t) for t in analysis.tokens()], + "ksonValue": _serialize_kson_value(analysis.kson_value()), + } + + +def handle_parse_schema(req): + result = Kson.parse_schema(req["schemaKson"]) + if isinstance(result, SchemaResult.Success): + return {"command": "parseSchema", "success": True} + else: + return { + "command": "parseSchema", + "success": False, + "errors": [_serialize_message(m) for m in result.errors()], + } + + +def handle_validate_embed_rule(req): + rule_obj = req["embedBlockRule"] + result = EmbedRule.from_path_pattern(rule_obj["pathPattern"], rule_obj.get("tag")) + if isinstance(result, EmbedRuleResult.Success): + return {"command": "validateEmbedRule", "success": True} + else: + return {"command": "validateEmbedRule", "success": False, "error": result.message()} + + +def handle_validate(req): + schema_result = Kson.parse_schema(req["schemaKson"]) + if isinstance(schema_result, SchemaResult.Failure): + return { + "command": "validate", + "success": False, + "errors": [_serialize_message(m) for m in schema_result.errors()], + } + + validator = schema_result.schema_validator() + errors = validator.validate(req["kson"], req.get("filepath")) + return { + "command": "validate", + "success": len(errors) == 0, + "errors": [_serialize_message(m) for m in errors], + } + + +HANDLERS = { + "format": handle_format, + "toJson": handle_to_json, + "toYaml": handle_to_yaml, + "analyze": handle_analyze, + "parseSchema": handle_parse_schema, + "validate": handle_validate, + "validateEmbedRule": handle_validate_embed_rule, +} + + +class KsonHandler(BaseHTTPRequestHandler): + def do_POST(self): + length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(length) + + try: + req = json.loads(body) + except json.JSONDecodeError as e: + self._send_error(400, f"Invalid JSON: {e}") + return + + command = req.get("command") + handler = HANDLERS.get(command) + if handler is None: + self._send_error(400, f"Unknown command: {command}") + return + + try: + response = handler(req) + except Exception as e: + self._send_error(500, str(e)) + return + + self._send_json(200, response) + + def _send_json(self, status, obj): + data = json.dumps(obj).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def _send_error(self, status, message): + self._send_json(status, {"internal_error": message}) + + def log_message(self, format, *args): + # Suppress default stderr logging + pass + + +def main(): + port = int(sys.argv[1]) if len(sys.argv) > 1 else 8080 + server = HTTPServer(("127.0.0.1", port), KsonHandler) + print(f"Listening on http://127.0.0.1:{port}", flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + except BaseException as e: + print(f"Exception! {e}") + print("Shutting down...") + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/settings.gradle.kts b/settings.gradle.kts index ed22c46e..37450566 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -14,8 +14,10 @@ pluginManagement { rootProject.name = "kson" include("native-tests") include("kson-lib") +include("kson-lib-http") include("kson-tooling-lib") include("lib-python") +include("lib-python:kson-lib-tests") include("lib-rust") include("tooling:jetbrains") include("tooling:language-server-protocol") From 9083bc10c90ad41ffd0f7606b8a7e9f5353f3acc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adolfo=20Ochagav=C3=ADa?= Date: Fri, 20 Feb 2026 17:17:46 -0300 Subject: [PATCH 4/4] Run `kson-lib` tests against `lib-rust` Here we follow the same approach used for testing the `lib-python` bindings. There is some code duplication in `ServerExtension.kt`, but I think we can tolerate that for now. Here is what we did: - We LLM-generated a Rust server that receives requests and uses the bindings to handle them (with minimal manual tweaks). - We created a `:lib-rust:kson-lib-tests` project responsible for actually running the `kson-lib` tests against the Rust server. --- lib-rust/.gitignore | 2 - lib-rust/kson-lib-tests/build.gradle.kts | 76 ++ .../src/jvmTest/kotlin/ServerExtension.kt | 56 + .../org.junit.jupiter.api.extension.Extension | 1 + lib-rust/kson-test-server/Cargo.lock | 1103 +++++++++++++++++ lib-rust/kson-test-server/Cargo.toml | 14 + lib-rust/kson-test-server/src/main.rs | 438 +++++++ settings.gradle.kts | 1 + 8 files changed, 1689 insertions(+), 2 deletions(-) delete mode 100644 lib-rust/.gitignore create mode 100644 lib-rust/kson-lib-tests/build.gradle.kts create mode 100644 lib-rust/kson-lib-tests/src/jvmTest/kotlin/ServerExtension.kt create mode 100644 lib-rust/kson-lib-tests/src/jvmTest/resources/META-INF/services/org.junit.jupiter.api.extension.Extension create mode 100644 lib-rust/kson-test-server/Cargo.lock create mode 100644 lib-rust/kson-test-server/Cargo.toml create mode 100644 lib-rust/kson-test-server/src/main.rs diff --git a/lib-rust/.gitignore b/lib-rust/.gitignore deleted file mode 100644 index 6cba219f..00000000 --- a/lib-rust/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -artifacts -kotlin diff --git a/lib-rust/kson-lib-tests/build.gradle.kts b/lib-rust/kson-lib-tests/build.gradle.kts new file mode 100644 index 00000000..4841c624 --- /dev/null +++ b/lib-rust/kson-lib-tests/build.gradle.kts @@ -0,0 +1,76 @@ +plugins { + kotlin("multiplatform") +} + +repositories { + mavenCentral() +} + +val releaseBuildDir: String = project(":lib-rust").projectDir.resolve("kson-test-server/target/release").absolutePath + +val syncCommonTestSources by tasks.registering(Sync::class) { + from(project(":kson-lib").file("src/commonTest/kotlin")) + into(layout.buildDirectory.dir("commonTestSources")) +} + +val syncJvmTestSources by tasks.registering(Sync::class) { + from(project(":kson-lib").file("src/jvmTest/kotlin")) + into(layout.buildDirectory.dir("jvmTestSources")) +} + +val buildTestServer by tasks.registering(Exec::class) { + dependsOn(":kson-lib:buildWithGraalVmNativeImage") + + val nativeArtifactsDir = project(":kson-lib").projectDir.resolve("build/kotlin/compileGraalVmNativeImage").absolutePath + + environment( + Pair("KSON_PREBUILT_BIN_DIR", nativeArtifactsDir), + Pair("KSON_COPY_SHARED_LIBRARY_TO_DIR", releaseBuildDir), + ) + + val cargoManifestPath = project(":lib-rust").projectDir.resolve("kson-test-server/Cargo.toml") + + group = "build" + workingDir = project(":lib-rust").projectDir + commandLine = "./pixiw run cargo build --release --manifest-path $cargoManifestPath".split(" ") + standardOutput = System.out + errorOutput = System.err + isIgnoreExitValue = false +} + +tasks.withType { + dependsOn(buildTestServer) + + systemProperty("releaseBuildDir", releaseBuildDir) + + useJUnitPlatform() + jvmArgs("-Djunit.jupiter.extensions.autodetection.enabled=true") +} + +kotlin { + jvm() + + sourceSets { + commonTest { + dependencies { + implementation(project(":kson-lib-http")) + implementation(kotlin("test")) + } + + kotlin { + srcDir(syncCommonTestSources) + } + } + jvmTest { + dependencies { + implementation("org.junit.jupiter:junit-jupiter-api:5.14.2") + runtimeOnly("org.junit.jupiter:junit-jupiter-engine:5.14.2") + } + + kotlin { + srcDir(syncJvmTestSources) + } + } + } +} + diff --git a/lib-rust/kson-lib-tests/src/jvmTest/kotlin/ServerExtension.kt b/lib-rust/kson-lib-tests/src/jvmTest/kotlin/ServerExtension.kt new file mode 100644 index 00000000..59133d62 --- /dev/null +++ b/lib-rust/kson-lib-tests/src/jvmTest/kotlin/ServerExtension.kt @@ -0,0 +1,56 @@ +import org.junit.jupiter.api.extension.BeforeAllCallback +import org.junit.jupiter.api.extension.ExtensionContext +import org.kson.Kson +import java.io.File +import java.net.Socket + +/** + * A JUnit extension that makes sure a KSON server is running before all tests and gets + * shut down at the end + */ +class ServerExtension : BeforeAllCallback, AutoCloseable { + + companion object { + private var started = false + private var process: Process? = null + } + + override fun beforeAll(context: ExtensionContext) { + if (!started) { + started = true + + // Register for cleanup when the root context closes (after all tests) + context.root.getStore(ExtensionContext.Namespace.GLOBAL) + .put("serverExtension", this) + + val releaseBuildDir = File(System.getProperty("releaseBuildDir")) + val port = 8081 + Kson.setPort(port) + + val processBuilder = ProcessBuilder(listOf("./kson-test-server", port.toString())) + .directory(releaseBuildDir) + .redirectOutput(ProcessBuilder.Redirect.INHERIT) + .redirectErrorStream(true); + + processBuilder.environment()["LD_LIBRARY_PATH"] = releaseBuildDir.absolutePath + + process = processBuilder.start() + + // Wait for readiness + repeat(30) { + try { + Socket("localhost", port).close() + return + } catch (_: Exception) { + Thread.sleep(1000) + } + } + throw RuntimeException("Server did not start in time") + } + } + + override fun close() { + process?.destroy() + process?.waitFor() + } +} diff --git a/lib-rust/kson-lib-tests/src/jvmTest/resources/META-INF/services/org.junit.jupiter.api.extension.Extension b/lib-rust/kson-lib-tests/src/jvmTest/resources/META-INF/services/org.junit.jupiter.api.extension.Extension new file mode 100644 index 00000000..849fdf0a --- /dev/null +++ b/lib-rust/kson-lib-tests/src/jvmTest/resources/META-INF/services/org.junit.jupiter.api.extension.Extension @@ -0,0 +1 @@ +ServerExtension diff --git a/lib-rust/kson-test-server/Cargo.lock b/lib-rust/kson-test-server/Cargo.lock new file mode 100644 index 00000000..dc4827f7 --- /dev/null +++ b/lib-rust/kson-test-server/Cargo.lock @@ -0,0 +1,1103 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bindgen" +version = "0.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", +] + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "kson-rs" +version = "0.3.0-dev" +dependencies = [ + "kson-sys", +] + +[[package]] +name = "kson-sys" +version = "0.3.0-dev" +dependencies = [ + "anyhow", + "bindgen", + "flate2", + "tar", + "ureq", +] + +[[package]] +name = "kson-test-server" +version = "0.1.0" +dependencies = [ + "axum", + "kson-rs", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", + "redox_syscall", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc97a28575b85cfedf2a7e7d3cc64b3e11bd8ac766666318003abbacc7a21fc" +dependencies = [ + "base64", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf-8", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/lib-rust/kson-test-server/Cargo.toml b/lib-rust/kson-test-server/Cargo.toml new file mode 100644 index 00000000..89db7acc --- /dev/null +++ b/lib-rust/kson-test-server/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "kson-test-server" +version = "0.1.0" +edition = "2024" + +[profile.release] +rpath = true + +[dependencies] +kson = { package = "kson-rs", path = "../kson" } +axum = "0.8" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } diff --git a/lib-rust/kson-test-server/src/main.rs b/lib-rust/kson-test-server/src/main.rs new file mode 100644 index 00000000..7b38fbf0 --- /dev/null +++ b/lib-rust/kson-test-server/src/main.rs @@ -0,0 +1,438 @@ +use axum::{Router, routing::post, Json}; +use serde::Deserialize; +use serde_json::Value; + +use kson::{ + EmbedRule, EmbedRuleResult, FormatOptions, FormattingStyle, IndentType, Kson, KsonValue, + indent_type, kson_value, transpile_options, +}; + +// --- Request types --- + +#[derive(Deserialize)] +#[serde(tag = "command")] +enum Request { + #[serde(rename = "format")] + Format { + kson: String, + #[serde(rename = "formatOptions")] + format_options: Option, + }, + #[serde(rename = "toJson")] + ToJson { + kson: String, + #[serde(rename = "retainEmbedTags", default = "default_true")] + retain_embed_tags: bool, + }, + #[serde(rename = "toYaml")] + ToYaml { + kson: String, + #[serde(rename = "retainEmbedTags", default = "default_true")] + retain_embed_tags: bool, + }, + #[serde(rename = "analyze")] + Analyze { + kson: String, + filepath: Option, + }, + #[serde(rename = "parseSchema")] + ParseSchema { + #[serde(rename = "schemaKson")] + schema_kson: String, + }, + #[serde(rename = "validate")] + Validate { + #[serde(rename = "schemaKson")] + schema_kson: String, + kson: String, + filepath: Option, + }, + #[serde(rename = "validateEmbedRule")] + ValidateEmbedRule { + #[serde(rename = "embedBlockRule")] + embed_block_rule: EmbedRuleDto, + }, +} + +fn default_true() -> bool { + true +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct FormatOptionsDto { + indent_type: Option, + formatting_style: Option, + embed_block_rules: Option>, +} + +#[derive(Deserialize)] +struct IndentTypeDto { + r#type: String, + size: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct EmbedRuleDto { + path_pattern: String, + tag: Option, +} + +// --- Response serialization helpers --- + +fn serialize_position(pos: kson::Position) -> Value { + serde_json::json!({ + "line": pos.line(), + "column": pos.column(), + }) +} + +fn serialize_message(msg: kson::Message) -> Value { + let severity = match msg.severity() { + kson::MessageSeverity::Error => "ERROR", + kson::MessageSeverity::Warning => "WARNING", + }; + serde_json::json!({ + "message": msg.message(), + "severity": severity, + "start": serialize_position(msg.start()), + "end": serialize_position(msg.end()), + }) +} + +fn serialize_token(token: kson::Token) -> Value { + let token_type = match token.token_type() { + kson::TokenType::CurlyBraceL => "CURLY_BRACE_L", + kson::TokenType::CurlyBraceR => "CURLY_BRACE_R", + kson::TokenType::SquareBracketL => "SQUARE_BRACKET_L", + kson::TokenType::SquareBracketR => "SQUARE_BRACKET_R", + kson::TokenType::AngleBracketL => "ANGLE_BRACKET_L", + kson::TokenType::AngleBracketR => "ANGLE_BRACKET_R", + kson::TokenType::Colon => "COLON", + kson::TokenType::Dot => "DOT", + kson::TokenType::EndDash => "END_DASH", + kson::TokenType::Comma => "COMMA", + kson::TokenType::Comment => "COMMENT", + kson::TokenType::EmbedOpenDelim => "EMBED_OPEN_DELIM", + kson::TokenType::EmbedCloseDelim => "EMBED_CLOSE_DELIM", + kson::TokenType::EmbedTag => "EMBED_TAG", + kson::TokenType::EmbedPreambleNewline => "EMBED_PREAMBLE_NEWLINE", + kson::TokenType::EmbedContent => "EMBED_CONTENT", + kson::TokenType::False => "FALSE", + kson::TokenType::UnquotedString => "UNQUOTED_STRING", + kson::TokenType::IllegalChar => "ILLEGAL_CHAR", + kson::TokenType::ListDash => "LIST_DASH", + kson::TokenType::Null => "NULL", + kson::TokenType::Number => "NUMBER", + kson::TokenType::StringOpenQuote => "STRING_OPEN_QUOTE", + kson::TokenType::StringCloseQuote => "STRING_CLOSE_QUOTE", + kson::TokenType::StringContent => "STRING_CONTENT", + kson::TokenType::True => "TRUE", + kson::TokenType::Whitespace => "WHITESPACE", + kson::TokenType::Eof => "EOF", + }; + serde_json::json!({ + "tokenType": token_type, + "text": token.text(), + "start": serialize_position(token.start()), + "end": serialize_position(token.end()), + }) +} + +fn serialize_kson_value(value: &KsonValue) -> Value { + match value { + KsonValue::KsonObject(obj) => { + let props = obj.properties(); + let property_keys = obj.property_keys(); + let mut serialized_props: serde_json::Map = serde_json::Map::new(); + for (key, val) in &props { + serialized_props.insert(key.clone(), serialize_kson_value(val)); + } + let mut serialized_keys: serde_json::Map = serde_json::Map::new(); + for (key, kson_str) in &property_keys { + serialized_keys.insert(key.clone(), serialize_kson_string(kson_str)); + } + serde_json::json!({ + "type": "OBJECT", + "properties": serialized_props, + "propertyKeys": serialized_keys, + "start": serialize_position(obj.start()), + "end": serialize_position(obj.end()), + }) + } + KsonValue::KsonArray(arr) => { + let elements: Vec = arr.elements().iter().map(serialize_kson_value).collect(); + serde_json::json!({ + "type": "ARRAY", + "elements": elements, + "start": serialize_position(arr.start()), + "end": serialize_position(arr.end()), + }) + } + KsonValue::KsonString(s) => serialize_kson_string(s), + KsonValue::KsonNumber(num) => match num { + kson_value::KsonNumber::Integer(i) => { + serde_json::json!({ + "type": "INTEGER", + "value": i.value(), + "start": serialize_position(i.start()), + "end": serialize_position(i.end()), + }) + } + kson_value::KsonNumber::Decimal(d) => { + serde_json::json!({ + "type": "DECIMAL", + "value": d.value(), + "start": serialize_position(d.start()), + "end": serialize_position(d.end()), + }) + } + }, + KsonValue::KsonBoolean(b) => { + serde_json::json!({ + "type": "BOOLEAN", + "value": b.value(), + "start": serialize_position(b.start()), + "end": serialize_position(b.end()), + }) + } + KsonValue::KsonNull(n) => { + serde_json::json!({ + "type": "NULL", + "start": serialize_position(n.start()), + "end": serialize_position(n.end()), + }) + } + KsonValue::KsonEmbed(e) => { + serde_json::json!({ + "type": "EMBED", + "tag": e.tag(), + "content": e.content(), + "start": serialize_position(e.start()), + "end": serialize_position(e.end()), + }) + } + } +} + +fn serialize_kson_string(s: &kson_value::KsonString) -> Value { + serde_json::json!({ + "type": "STRING", + "value": s.value(), + "start": serialize_position(s.start()), + "end": serialize_position(s.end()), + }) +} + +// --- Request handling --- + +fn convert_indent_type(dto: &IndentTypeDto) -> IndentType { + match dto.r#type.as_str() { + "tabs" => IndentType::Tabs(indent_type::Tabs::new()), + _ => IndentType::Spaces(indent_type::Spaces::new(dto.size.unwrap_or(2))), + } +} + +fn convert_formatting_style(s: &str) -> FormattingStyle { + match s { + "DELIMITED" => FormattingStyle::Delimited, + "COMPACT" => FormattingStyle::Compact, + "CLASSIC" => FormattingStyle::Classic, + _ => FormattingStyle::Plain, + } +} + +fn convert_format_options(dto: Option<&FormatOptionsDto>) -> FormatOptions { + let indent_type = dto + .and_then(|d| d.indent_type.as_ref()) + .map(convert_indent_type) + .unwrap_or_else(|| IndentType::Spaces(indent_type::Spaces::new(2))); + + let formatting_style = dto + .and_then(|d| d.formatting_style.as_deref()) + .map(convert_formatting_style) + .unwrap_or(FormattingStyle::Plain); + + let embed_rules: Vec = dto + .and_then(|d| d.embed_block_rules.as_ref()) + .map(|rules| { + rules + .iter() + .filter_map(|r| { + match EmbedRule::from_path_pattern( + &r.path_pattern, + r.tag.as_deref(), + ) { + EmbedRuleResult::Success(s) => Some(s.embed_rule()), + EmbedRuleResult::Failure(_) => None, + } + }) + .collect() + }) + .unwrap_or_default(); + + FormatOptions::new(indent_type, formatting_style, &embed_rules) +} + +fn handle_request(request: Request) -> Value { + match request { + Request::Format { + kson, + format_options, + } => { + let opts = convert_format_options(format_options.as_ref()); + let output = Kson::format(&kson, opts); + serde_json::json!({ + "command": "format", + "success": true, + "output": output, + }) + } + Request::ToJson { + kson, + retain_embed_tags, + } => { + let opts = transpile_options::Json::new(retain_embed_tags); + match Kson::to_json(&kson, opts) { + Ok(success) => serde_json::json!({ + "command": "toJson", + "success": true, + "output": success.output(), + }), + Err(failure) => { + let errors: Vec = + failure.errors().into_iter().map(serialize_message).collect(); + serde_json::json!({ + "command": "toJson", + "success": false, + "errors": errors, + }) + } + } + } + Request::ToYaml { + kson, + retain_embed_tags, + } => { + let opts = transpile_options::Yaml::new(retain_embed_tags); + match Kson::to_yaml(&kson, opts) { + Ok(success) => serde_json::json!({ + "command": "toYaml", + "success": true, + "output": success.output(), + }), + Err(failure) => { + let errors: Vec = + failure.errors().into_iter().map(serialize_message).collect(); + serde_json::json!({ + "command": "toYaml", + "success": false, + "errors": errors, + }) + } + } + } + Request::Analyze { kson, filepath } => { + let analysis = Kson::analyze(&kson, filepath.as_deref()); + let errors: Vec = analysis + .errors() + .into_iter() + .map(serialize_message) + .collect(); + let tokens: Vec = analysis + .tokens() + .into_iter() + .map(serialize_token) + .collect(); + let kson_value = analysis + .kson_value() + .as_ref() + .map(serialize_kson_value); + serde_json::json!({ + "command": "analyze", + "errors": errors, + "tokens": tokens, + "ksonValue": kson_value, + }) + } + Request::ParseSchema { schema_kson } => match Kson::parse_schema(&schema_kson) { + Ok(_) => serde_json::json!({ + "command": "parseSchema", + "success": true, + }), + Err(failure) => { + let errors: Vec = + failure.errors().into_iter().map(serialize_message).collect(); + serde_json::json!({ + "command": "parseSchema", + "success": false, + "errors": errors, + }) + } + }, + Request::Validate { + schema_kson, + kson, + filepath, + } => match Kson::parse_schema(&schema_kson) { + Ok(success) => { + let validator = success.schema_validator(); + let errors: Vec = validator + .validate(&kson, filepath.as_deref()) + .into_iter() + .map(serialize_message) + .collect(); + let is_success = errors.is_empty(); + serde_json::json!({ + "command": "validate", + "success": is_success, + "errors": errors, + }) + } + Err(failure) => { + let errors: Vec = + failure.errors().into_iter().map(serialize_message).collect(); + serde_json::json!({ + "command": "validate", + "success": false, + "errors": errors, + }) + } + }, + Request::ValidateEmbedRule { embed_block_rule } => { + match EmbedRule::from_path_pattern( + &embed_block_rule.path_pattern, + embed_block_rule.tag.as_deref(), + ) { + EmbedRuleResult::Success(_) => serde_json::json!({ + "command": "validateEmbedRule", + "success": true, + }), + EmbedRuleResult::Failure(failure) => serde_json::json!({ + "command": "validateEmbedRule", + "success": false, + "error": failure.message(), + }), + } + } + } +} + +async fn handler(Json(request): Json) -> Json { + Json(handle_request(request)) +} + +#[tokio::main] +async fn main() { + let port: u16 = std::env::args() + .nth(1) + .and_then(|s| s.parse().ok()) + .unwrap_or(3000); + + let app = Router::new().route("/", post(handler)); + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port)); + println!("Listening on {addr}"); + let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); + axum::serve(listener, app).await.unwrap(); +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 37450566..65138bf9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -19,6 +19,7 @@ include("kson-tooling-lib") include("lib-python") include("lib-python:kson-lib-tests") include("lib-rust") +include("lib-rust:kson-lib-tests") include("tooling:jetbrains") include("tooling:language-server-protocol") include("tooling:lsp-clients")