//! Language Server Protocol server for `.native` markup views, spoken over //! stdio by `native markup lsp`. //! //! v1 scope (model-agnostic, mirrors `native markup check`): //! - publishDiagnostics on didOpen/didChange via the shared parser and //! `ui_markup.validate` (line/column + teaching messages), plus the //! a11y lint's warnings (`ui_markup.collectA11yWarnings`) at warning //! severity once the document validates clean. //! - completion: element names after `<`, attribute/event names inside a //! tag, and icon names inside an icon-valued attribute's quotes (the //! built-in vocabulary plus the `app:` namespace prefix). //! - hover: one-line docs for element and attribute names. //! //! Binding paths and message tags are NOT validated here — that requires the //! app's concrete Model/Msg types, which only exist when the app builds. //! Documented future work: a build-integrated mode that loads the app's //! compiled binding metadata to check `{path}` and `on-*` tags. //! //! The protocol layer is hand-rolled on std only: Content-Length framing //! over `std.Io.Reader`/`std.Io.Writer` (injectable, so tests drive the //! whole server through fixed buffers) and `std.json` for messages. const std = @import("std"); const ui_markup = @import("ui_markup"); pub const server_name = "native-sdk-markup-lsp"; pub const server_version = "0.1.0"; // --------------------------------------------------------------- framing pub const FrameError = error{ InvalidFrame, EndOfStream, ReadFailed, StreamTooLong, OutOfMemory, }; /// Read one LSP frame: `Content-Length: N` headers, a blank line, then a /// body of exactly N bytes. Returns the body, owned by `allocator`. pub fn readFrame(allocator: std.mem.Allocator, in: *std.Io.Reader) FrameError![]u8 { var content_length: ?usize = null; while (true) { const raw = try in.takeDelimiterInclusive('\n'); const line = std.mem.trimEnd(u8, raw, "\r\n"); if (line.len == 0) break; const prefix = "content-length:"; if (line.len > prefix.len and std.ascii.eqlIgnoreCase(line[0..prefix.len], prefix)) { const value = std.mem.trim(u8, line[prefix.len..], " \t"); content_length = std.fmt.parseUnsigned(usize, value, 10) catch return error.InvalidFrame; } } const length = content_length orelse return error.InvalidFrame; const body = try allocator.alloc(u8, length); errdefer allocator.free(body); try in.readSliceAll(body); return body; } fn sendFrame(out: *std.Io.Writer, body: []const u8) !void { try out.print("Content-Length: {d}\r\n\r\n", .{body.len}); try out.writeAll(body); try out.flush(); } // ---------------------------------------------------------------- server pub const Server = struct { allocator: std.mem.Allocator, in: *std.Io.Reader, out: *std.Io.Writer, /// uri -> current document text; both allocated with `allocator`. documents: std.StringHashMapUnmanaged([]u8) = .empty, pub fn init(allocator: std.mem.Allocator, in: *std.Io.Reader, out: *std.Io.Writer) Server { return .{ .allocator = allocator, .in = in, .out = out }; } pub fn deinit(self: *Server) void { var it = self.documents.iterator(); while (it.next()) |entry| { self.allocator.free(entry.key_ptr.*); self.allocator.free(entry.value_ptr.*); } self.documents.deinit(self.allocator); } /// Serve until the client sends `exit` or the input stream ends. pub fn run(self: *Server) !void { while (true) { const body = readFrame(self.allocator, self.in) catch |err| switch (err) { error.EndOfStream => return, error.InvalidFrame => continue, else => return err, }; defer self.allocator.free(body); if (try self.handleMessage(body)) return; } } /// Returns true when the client asked the server to exit. fn handleMessage(self: *Server, body: []const u8) !bool { var arena_state = std.heap.ArenaAllocator.init(self.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const root = std.json.parseFromSliceLeaky(std.json.Value, arena, body, .{}) catch return false; if (root != .object) return false; const id = root.object.get("id"); const method_value = root.object.get("method") orelse return false; if (method_value != .string) return false; const method = method_value.string; const params: std.json.Value = root.object.get("params") orelse .null; if (std.mem.eql(u8, method, "initialize")) { try self.respondInitialize(arena, id); } else if (std.mem.eql(u8, method, "initialized")) { // nothing to do } else if (std.mem.eql(u8, method, "shutdown")) { try self.respondNull(arena, id); } else if (std.mem.eql(u8, method, "exit")) { return true; } else if (std.mem.eql(u8, method, "textDocument/didOpen")) { const text_document = member(params, "textDocument") orelse return false; const uri = stringMember(text_document, "uri") orelse return false; const text = stringMember(text_document, "text") orelse return false; const stored = try self.setDocument(uri, text); try self.publishDiagnostics(arena, uri, stored); } else if (std.mem.eql(u8, method, "textDocument/didChange")) { const text_document = member(params, "textDocument") orelse return false; const uri = stringMember(text_document, "uri") orelse return false; const changes = member(params, "contentChanges") orelse return false; if (changes != .array) return false; var new_text: ?[]const u8 = null; for (changes.array.items) |change| { // Full-document sync only (we advertise sync kind 1); skip // any incremental change a client sends anyway. if (member(change, "range") != null) continue; if (stringMember(change, "text")) |text| new_text = text; } if (new_text) |text| { const stored = try self.setDocument(uri, text); try self.publishDiagnostics(arena, uri, stored); } } else if (std.mem.eql(u8, method, "textDocument/didClose")) { const text_document = member(params, "textDocument") orelse return false; const uri = stringMember(text_document, "uri") orelse return false; self.removeDocument(uri); } else if (std.mem.eql(u8, method, "textDocument/completion")) { try self.respondCompletion(arena, id, params); } else if (std.mem.eql(u8, method, "textDocument/hover")) { try self.respondHover(arena, id, params); } else if (id != null) { try self.respondMethodNotFound(arena, id, method); } return false; } fn setDocument(self: *Server, uri: []const u8, text: []const u8) ![]const u8 { const key = try self.allocator.dupe(u8, uri); errdefer self.allocator.free(key); const value = try self.allocator.dupe(u8, text); errdefer self.allocator.free(value); const gop = try self.documents.getOrPut(self.allocator, key); if (gop.found_existing) { self.allocator.free(key); self.allocator.free(gop.value_ptr.*); } gop.value_ptr.* = value; return value; } fn removeDocument(self: *Server, uri: []const u8) void { if (self.documents.fetchRemove(uri)) |entry| { self.allocator.free(entry.key); self.allocator.free(entry.value); } } fn respondInitialize(self: *Server, arena: std.mem.Allocator, id: ?std.json.Value) !void { var body: std.Io.Writer.Allocating = .init(arena); var js: std.json.Stringify = .{ .writer = &body.writer }; try js.beginObject(); try beginResponse(&js, id); try js.objectField("result"); try js.beginObject(); try js.objectField("capabilities"); try js.beginObject(); try js.objectField("textDocumentSync"); try js.write(1); // full-document sync try js.objectField("completionProvider"); try js.beginObject(); try js.objectField("triggerCharacters"); try js.write(&[_][]const u8{ "<", " " }); try js.endObject(); try js.objectField("hoverProvider"); try js.write(true); try js.endObject(); try js.objectField("serverInfo"); try js.beginObject(); try js.objectField("name"); try js.write(server_name); try js.objectField("version"); try js.write(server_version); try js.endObject(); try js.endObject(); try js.endObject(); try sendFrame(self.out, body.written()); } fn respondNull(self: *Server, arena: std.mem.Allocator, id: ?std.json.Value) !void { var body: std.Io.Writer.Allocating = .init(arena); var js: std.json.Stringify = .{ .writer = &body.writer }; try js.beginObject(); try beginResponse(&js, id); try js.objectField("result"); try js.write(null); try js.endObject(); try sendFrame(self.out, body.written()); } fn respondMethodNotFound(self: *Server, arena: std.mem.Allocator, id: ?std.json.Value, method: []const u8) !void { var body: std.Io.Writer.Allocating = .init(arena); var js: std.json.Stringify = .{ .writer = &body.writer }; try js.beginObject(); try beginResponse(&js, id); try js.objectField("error"); try js.beginObject(); try js.objectField("code"); try js.write(-32601); try js.objectField("message"); try js.write(try std.fmt.allocPrint(arena, "method not found: {s}", .{method})); try js.endObject(); try js.endObject(); try sendFrame(self.out, body.written()); } fn publishDiagnostics(self: *Server, arena: std.mem.Allocator, uri: []const u8, text: []const u8) !void { const finding = analyze(arena, text); var body: std.Io.Writer.Allocating = .init(arena); var js: std.json.Stringify = .{ .writer = &body.writer }; try js.beginObject(); try js.objectField("jsonrpc"); try js.write("2.0"); try js.objectField("method"); try js.write("textDocument/publishDiagnostics"); try js.objectField("params"); try js.beginObject(); try js.objectField("uri"); try js.write(uri); try js.objectField("diagnostics"); try js.beginArray(); if (finding) |info| { try writeDiagnostic(&js, text, info, 1); // Error } else { // The a11y lint's warning half only reports on a document // that validates clean, mirroring `native markup check`. var warning_storage: [ui_markup.max_a11y_warnings]ui_markup.MarkupErrorInfo = undefined; for (analyzeWarnings(arena, text, &warning_storage)) |info| { try writeDiagnostic(&js, text, info, 2); // Warning } } try js.endArray(); try js.endObject(); try js.endObject(); try sendFrame(self.out, body.written()); } fn writeDiagnostic(js: *std.json.Stringify, text: []const u8, info: ui_markup.MarkupErrorInfo, severity: u8) !void { const line = if (info.line > 0) info.line - 1 else 0; const character = if (info.column > 0) info.column - 1 else 0; const end_character = diagnosticEndCharacter(text, line, character); try js.beginObject(); try js.objectField("range"); try writeRange(js, line, character, line, end_character); try js.objectField("severity"); try js.write(severity); try js.objectField("source"); try js.write("native-markup"); try js.objectField("message"); try js.write(info.message); try js.endObject(); } fn respondCompletion(self: *Server, arena: std.mem.Allocator, id: ?std.json.Value, params: std.json.Value) !void { var context: CompletionContext = .none; if (self.documentForParams(params)) |text| { if (positionFromParams(params)) |position| { context = completionContext(text, offsetForPosition(text, position.line, position.character)); } } var body: std.Io.Writer.Allocating = .init(arena); var js: std.json.Stringify = .{ .writer = &body.writer }; try js.beginObject(); try beginResponse(&js, id); try js.objectField("result"); try js.beginObject(); try js.objectField("isIncomplete"); try js.write(false); try js.objectField("items"); try js.beginArray(); switch (context) { .none => {}, .elements => { for (element_docs) |doc| try writeCompletionItem(&js, doc.name, .class, "markup element", doc.doc); for (structure_docs) |doc| try writeCompletionItem(&js, doc.name, .keyword, "markup structure tag", doc.doc); }, .attributes => |element_name| { if (std.mem.eql(u8, element_name, "for")) { for (for_attr_docs) |doc| try writeCompletionItem(&js, doc.name, .property, "for attribute", doc.doc); } else if (std.mem.eql(u8, element_name, "if")) { for (if_attr_docs) |doc| try writeCompletionItem(&js, doc.name, .property, "if attribute", doc.doc); } else if (std.mem.eql(u8, element_name, "else")) { // else takes no attributes } else if (std.mem.eql(u8, element_name, "markdown")) { for (markdown_attr_docs) |doc| try writeCompletionItem(&js, doc.name, .property, "markdown attribute", doc.doc); } else if (std.mem.eql(u8, element_name, "stepper")) { for (stepper_attr_docs) |doc| try writeCompletionItem(&js, doc.name, .property, "stepper attribute", doc.doc); } else if (std.mem.eql(u8, element_name, "step")) { // step takes no attributes; its content is the label. } else if (std.mem.eql(u8, element_name, "timeline")) { for (timeline_attr_docs) |doc| try writeCompletionItem(&js, doc.name, .property, "timeline attribute", doc.doc); } else if (std.mem.eql(u8, element_name, "timeline-item")) { for (timeline_item_attr_docs) |doc| try writeCompletionItem(&js, doc.name, .property, "timeline-item attribute", doc.doc); } else if (std.mem.eql(u8, element_name, "chart")) { for (chart_attr_docs) |doc| try writeCompletionItem(&js, doc.name, .property, "chart attribute", doc.doc); } else if (std.mem.eql(u8, element_name, "series")) { for (series_attr_docs) |doc| try writeCompletionItem(&js, doc.name, .property, "series attribute", doc.doc); } else if (std.mem.eql(u8, element_name, "context-menu")) { // context-menu takes no attributes; its menu-item // children carry on-press and disabled. } else if (std.mem.eql(u8, element_name, "input-group")) { for (input_group_attr_docs) |doc| try writeCompletionItem(&js, doc.name, .property, "input-group attribute", doc.doc); } else if (std.mem.eql(u8, element_name, "input-group-actions")) { for (input_group_actions_attr_docs) |doc| try writeCompletionItem(&js, doc.name, .property, "input-group-actions attribute", doc.doc); } else if (std.mem.eql(u8, element_name, "span")) { for (span_attr_docs) |doc| try writeCompletionItem(&js, doc.name, .property, "span attribute", doc.doc); } else if (std.mem.eql(u8, element_name, "reactions")) { for (reactions_attr_docs) |doc| try writeCompletionItem(&js, doc.name, .property, "reactions attribute", doc.doc); } else if (std.mem.eql(u8, element_name, "avatar")) { for (avatar_attr_docs) |doc| try writeCompletionItem(&js, doc.name, .property, "avatar attribute", doc.doc); for (attribute_docs) |doc| try writeCompletionItem(&js, doc.name, .property, "markup attribute", doc.doc); for (event_docs) |doc| try writeCompletionItem(&js, doc.name, .event, "markup event", doc.doc); } else if (std.mem.eql(u8, element_name, "dropdown-menu")) { for (anchor_attr_docs) |doc| try writeCompletionItem(&js, doc.name, .property, "dropdown-menu attribute", doc.doc); for (attribute_docs) |doc| try writeCompletionItem(&js, doc.name, .property, "markup attribute", doc.doc); for (event_docs) |doc| try writeCompletionItem(&js, doc.name, .event, "markup event", doc.doc); } else { for (attribute_docs) |doc| try writeCompletionItem(&js, doc.name, .property, "markup attribute", doc.doc); for (event_docs) |doc| try writeCompletionItem(&js, doc.name, .event, "markup event", doc.doc); } }, .icon_values => { for (ui_markup.known_icon_names) |name| { try writeCompletionItem(&js, name, .value, "built-in icon", "One of the built-in vector icons (canvas.icons.known_icon_names); validated at build time."); } try writeCompletionItem(&js, "app:", .value, "app icon namespace", "app: draws an icon the app registered at boot with canvas.icons.registerAppIcons; `native check` verifies the name against the model contract's app_icons list."); }, } try js.endArray(); try js.endObject(); try js.endObject(); try sendFrame(self.out, body.written()); } fn respondHover(self: *Server, arena: std.mem.Allocator, id: ?std.json.Value, params: std.json.Value) !void { var hover: ?HoverResult = null; if (self.documentForParams(params)) |text| { if (positionFromParams(params)) |position| { hover = hoverAt(text, offsetForPosition(text, position.line, position.character)); } } var body: std.Io.Writer.Allocating = .init(arena); var js: std.json.Stringify = .{ .writer = &body.writer }; try js.beginObject(); try beginResponse(&js, id); try js.objectField("result"); if (hover) |result| { const value = try std.fmt.allocPrint(arena, "**{s}** — {s}", .{ result.name, result.doc }); try js.beginObject(); try js.objectField("contents"); try js.beginObject(); try js.objectField("kind"); try js.write("markdown"); try js.objectField("value"); try js.write(value); try js.endObject(); try js.endObject(); } else { try js.write(null); } try js.endObject(); try sendFrame(self.out, body.written()); } fn documentForParams(self: *Server, params: std.json.Value) ?[]const u8 { const text_document = member(params, "textDocument") orelse return null; const uri = stringMember(text_document, "uri") orelse return null; return self.documents.get(uri); } }; fn beginResponse(js: *std.json.Stringify, id: ?std.json.Value) !void { try js.objectField("jsonrpc"); try js.write("2.0"); try js.objectField("id"); try js.write(id orelse @as(std.json.Value, .null)); } const CompletionKind = enum(u8) { keyword = 14, class = 7, property = 10, value = 12, event = 23, }; fn writeCompletionItem(js: *std.json.Stringify, label: []const u8, kind: CompletionKind, detail: []const u8, documentation: []const u8) !void { try js.beginObject(); try js.objectField("label"); try js.write(label); try js.objectField("kind"); try js.write(@intFromEnum(kind)); try js.objectField("detail"); try js.write(detail); try js.objectField("documentation"); try js.write(documentation); try js.endObject(); } fn writeRange(js: *std.json.Stringify, start_line: usize, start_character: usize, end_line: usize, end_character: usize) !void { try js.beginObject(); try js.objectField("start"); try writePosition(js, start_line, start_character); try js.objectField("end"); try writePosition(js, end_line, end_character); try js.endObject(); } fn writePosition(js: *std.json.Stringify, line: usize, character: usize) !void { try js.beginObject(); try js.objectField("line"); try js.write(line); try js.objectField("character"); try js.write(character); try js.endObject(); } fn member(value: std.json.Value, name: []const u8) ?std.json.Value { if (value != .object) return null; return value.object.get(name); } fn stringMember(value: std.json.Value, name: []const u8) ?[]const u8 { const field = member(value, name) orelse return null; if (field != .string) return null; return field.string; } const Position = struct { line: usize, character: usize }; fn positionFromParams(params: std.json.Value) ?Position { const position = member(params, "position") orelse return null; const line = member(position, "line") orelse return null; const character = member(position, "character") orelse return null; if (line != .integer or character != .integer) return null; if (line.integer < 0 or character.integer < 0) return null; return .{ .line = @intCast(line.integer), .character = @intCast(character.integer) }; } // --------------------------------------------------------------- analysis /// Parse + structurally validate a document; returns the first finding or /// null when the markup is clean. Messages are static strings, so the /// result outlives `arena`. pub fn analyze(arena: std.mem.Allocator, source: []const u8) ?ui_markup.MarkupErrorInfo { var parser = ui_markup.Parser.init(arena, source); const document = parser.parse() catch |err| switch (err) { error.OutOfMemory => return .{ .line = 1, .column = 1, .message = "out of memory while parsing" }, error.MarkupSyntax => return parser.diagnostic, }; return ui_markup.validate(document); } /// The a11y lint's warnings for a document that already validates clean /// (unnamed images, redundant labels); empty when the source does not /// even parse — the error diagnostic owns that turn. pub fn analyzeWarnings(arena: std.mem.Allocator, source: []const u8, storage: []ui_markup.MarkupErrorInfo) []const ui_markup.MarkupErrorInfo { var parser = ui_markup.Parser.init(arena, source); const document = parser.parse() catch return storage[0..0]; return ui_markup.collectA11yWarnings(document, storage); } /// Byte offset for a 0-based LSP position, clamped to the line's end. /// Positions are treated as byte columns; `.native` markup is ASCII in /// practice (UTF-16 column mapping is documented future work). pub fn offsetForPosition(text: []const u8, line: usize, character: usize) usize { var current_line: usize = 0; var index: usize = 0; while (current_line < line) { const newline = std.mem.indexOfScalarPos(u8, text, index, '\n') orelse return text.len; index = newline + 1; current_line += 1; } const line_end = std.mem.indexOfScalarPos(u8, text, index, '\n') orelse text.len; return @min(index + character, line_end); } /// End of the diagnostic range: extend across the name under the position /// (element findings point at the `<`, attribute findings at the name) so /// editors underline the whole identifier, not one character. fn diagnosticEndCharacter(text: []const u8, line: usize, character: usize) usize { const offset = offsetForPosition(text, line, character); var end = offset; if (end < text.len and text[end] == '<') end += 1; if (end < text.len and text[end] == '/') end += 1; while (end < text.len and isNameChar(text[end])) end += 1; if (end == offset) return character + 1; return character + (end - offset); } fn isNameChar(byte: u8) bool { return (byte >= 'a' and byte <= 'z') or (byte >= 'A' and byte <= 'Z') or (byte >= '0' and byte <= '9') or byte == '-' or byte == '_'; } // ------------------------------------------------------------- completion pub const CompletionContext = union(enum) { none, /// Right after `<` (possibly mid element name): offer element names. elements, /// Inside a tag after the element name: offer attributes/events. /// Payload is the element name. attributes: []const u8, /// Inside the quoted VALUE of an icon-valued attribute (``, or `icon="..."` on the labeled interactive /// elements): offer the built-in icon names plus the `app:` /// namespace prefix for app-registered icons. icon_values, }; pub fn completionContext(text: []const u8, offset: usize) CompletionContext { const open = lastTagOpen(text, offset) orelse return .none; if (insideQuotes(text, open, offset)) return attrValueContext(text, open, offset); var index = open + 1; if (index < offset and text[index] == '/') index += 1; const name_start = index; while (index < offset and isNameChar(text[index])) index += 1; if (index == offset) return .elements; return .{ .attributes = text[name_start..index] }; } /// Classify a position INSIDE an attribute value's quotes: icon-valued /// attributes complete their closed vocabulary; every other value is the /// author's (bindings, text, numbers), so no items. fn attrValueContext(text: []const u8, open: usize, offset: usize) CompletionContext { // The element name, right after `<`. var index = open + 1; if (index < offset and text[index] == '/') index += 1; const element_start = index; while (index < offset and isNameChar(text[index])) index += 1; const element_name = text[element_start..index]; // The unmatched opening quote the offset sits behind. var quote_open: ?usize = null; var position = index; while (position < @min(offset, text.len)) : (position += 1) { if (text[position] != '"') continue; quote_open = if (quote_open == null) position else null; } const value_open = quote_open orelse return .none; // The attribute name, read back across its `="`. if (value_open == 0 or text[value_open - 1] != '=') return .none; const attr_end = value_open - 1; var attr_start = attr_end; while (attr_start > 0 and isNameChar(text[attr_start - 1])) attr_start -= 1; const attr_name = text[attr_start..attr_end]; const icon_leaf = std.mem.eql(u8, element_name, "icon") and std.mem.eql(u8, attr_name, "name"); const inline_icon = std.mem.eql(u8, attr_name, "icon") and ui_markup.iconAttrElement(element_name); if (icon_leaf or inline_icon) return .icon_values; return .none; } /// Index of the `<` of the tag containing `offset`, or null when the /// position is in text content (a `>` closes the nearest tag first). fn lastTagOpen(text: []const u8, offset: usize) ?usize { var index = @min(offset, text.len); while (index > 0) { index -= 1; switch (text[index]) { '<' => return index, '>' => return null, else => {}, } } return null; } fn insideQuotes(text: []const u8, from: usize, to: usize) bool { var quotes: usize = 0; for (text[from..@min(to, text.len)]) |byte| { if (byte == '"') quotes += 1; } return quotes % 2 == 1; } // ------------------------------------------------------------------ hover pub const HoverResult = struct { name: []const u8, doc: []const u8, }; pub fn hoverAt(text: []const u8, offset: usize) ?HoverResult { if (text.len == 0) return null; const clamped = @min(offset, text.len - 1); var start = clamped + @intFromBool(isNameChar(text[clamped])); while (start > 0 and isNameChar(text[start - 1])) start -= 1; var end = start; while (end < text.len and isNameChar(text[end])) end += 1; if (start == end) return null; const word = text[start..end]; const is_element = (start >= 1 and text[start - 1] == '<') or (start >= 2 and text[start - 1] == '/' and text[start - 2] == '<'); if (is_element) { const doc = elementDoc(word) orelse return null; return .{ .name = word, .doc = doc }; } const open = lastTagOpen(text, start) orelse return null; if (insideQuotes(text, open, start)) return null; const doc = attributeDoc(word) orelse return null; return .{ .name = word, .doc = doc }; } // Documentation tables shared with the docs-site generator live in // markup_docs.zig; re-exported here so LSP consumers keep one import. pub const markup_docs = @import("markup_docs.zig"); pub const Doc = markup_docs.Doc; pub const element_docs = markup_docs.element_docs; pub const structure_docs = markup_docs.structure_docs; pub const attribute_docs = markup_docs.attribute_docs; pub const template_attr_docs = markup_docs.template_attr_docs; pub const for_attr_docs = markup_docs.for_attr_docs; pub const if_attr_docs = markup_docs.if_attr_docs; pub const markdown_attr_docs = markup_docs.markdown_attr_docs; pub const stepper_attr_docs = markup_docs.stepper_attr_docs; pub const timeline_attr_docs = markup_docs.timeline_attr_docs; pub const timeline_item_attr_docs = markup_docs.timeline_item_attr_docs; pub const avatar_attr_docs = markup_docs.avatar_attr_docs; pub const anchor_attr_docs = markup_docs.anchor_attr_docs; pub const chart_attr_docs = markup_docs.chart_attr_docs; pub const series_attr_docs = markup_docs.series_attr_docs; pub const input_group_attr_docs = markup_docs.input_group_attr_docs; pub const input_group_actions_attr_docs = markup_docs.input_group_actions_attr_docs; pub const span_attr_docs = markup_docs.span_attr_docs; pub const reactions_attr_docs = markup_docs.reactions_attr_docs; pub const event_docs = markup_docs.event_docs; pub const elementDoc = markup_docs.elementDoc; pub const attributeDoc = markup_docs.attributeDoc; // ------------------------------------------------------------------ tests const testing = std.testing; fn frame(allocator: std.mem.Allocator, body: []const u8) ![]u8 { return std.fmt.allocPrint(allocator, "Content-Length: {d}\r\n\r\n{s}", .{ body.len, body }); } test "readFrame parses Content-Length framing" { const allocator = testing.allocator; const input = "Content-Length: 7\r\nContent-Type: application/vscode-jsonrpc; charset=utf-8\r\n\r\n{\"a\":1}content-length: 7\r\n\r\n{\"b\":2}"; var reader = std.Io.Reader.fixed(input); const first = try readFrame(allocator, &reader); defer allocator.free(first); try testing.expectEqualStrings("{\"a\":1}", first); const second = try readFrame(allocator, &reader); defer allocator.free(second); try testing.expectEqualStrings("{\"b\":2}", second); try testing.expectError(error.EndOfStream, readFrame(allocator, &reader)); } test "readFrame rejects a frame without Content-Length" { const allocator = testing.allocator; var reader = std.Io.Reader.fixed("Content-Type: application/json\r\n\r\n{}"); try testing.expectError(error.InvalidFrame, readFrame(allocator, &reader)); } test "serve: initialize, didOpen with broken markup, publishDiagnostics round trip" { const allocator = testing.allocator; var arena_state = std.heap.ArenaAllocator.init(allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); // sits at line 2, column 3 (1-based) -> LSP line 1, character 2. const broken = "{\"jsonrpc\":\"2.0\",\"method\":\"textDocument/didOpen\",\"params\":{\"textDocument\":{" ++ "\"uri\":\"file:///tmp/app.native\",\"languageId\":\"native-markup\",\"version\":1," ++ "\"text\":\"\\n \\n\\n\"}}}"; const valid_doc = "{\"jsonrpc\":\"2.0\",\"method\":\"textDocument/didChange\",\"params\":{\"textDocument\":{" ++ "\"uri\":\"file:///tmp/app.native\",\"version\":2},\"contentChanges\":[{" ++ "\"text\":\"hi\"}]}}"; const avatar_doc = "{\"jsonrpc\":\"2.0\",\"method\":\"textDocument/didChange\",\"params\":{\"textDocument\":{" ++ "\"uri\":\"file:///tmp/app.native\",\"version\":3},\"contentChanges\":[{" ++ "\"text\":\"CT\"}]}}"; var input: std.Io.Writer.Allocating = .init(arena); for ([_][]const u8{ "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"capabilities\":{}}}", "{\"jsonrpc\":\"2.0\",\"method\":\"initialized\",\"params\":{}}", broken, valid_doc, "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"textDocument/completion\",\"params\":{\"textDocument\":{\"uri\":\"file:///tmp/app.native\"},\"position\":{\"line\":0,\"character\":5}}}", "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"textDocument/hover\",\"params\":{\"textDocument\":{\"uri\":\"file:///tmp/app.native\"},\"position\":{\"line\":0,\"character\":2}}}", avatar_doc, "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"textDocument/completion\",\"params\":{\"textDocument\":{\"uri\":\"file:///tmp/app.native\"},\"position\":{\"line\":0,\"character\":8}}}", "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"shutdown\"}", "{\"jsonrpc\":\"2.0\",\"method\":\"exit\"}", }) |body| { const framed = try frame(arena, body); try input.writer.writeAll(framed); } var reader = std.Io.Reader.fixed(input.written()); var output: std.Io.Writer.Allocating = .init(arena); var server = Server.init(allocator, &reader, &output.writer); defer server.deinit(); try server.run(); // Re-read the server's own frames. var out_reader = std.Io.Reader.fixed(output.written()); var bodies: std.ArrayList([]u8) = .empty; while (true) { const body = readFrame(arena, &out_reader) catch |err| switch (err) { error.EndOfStream => break, else => return err, }; try bodies.append(arena, body); } // initialize response, diagnostics (broken), diagnostics (clean), // completion, hover, diagnostics (avatar), avatar completion, // shutdown. try testing.expectEqual(@as(usize, 8), bodies.items.len); try testing.expect(std.mem.indexOf(u8, bodies.items[0], "\"id\":1") != null); try testing.expect(std.mem.indexOf(u8, bodies.items[0], "\"capabilities\"") != null); try testing.expect(std.mem.indexOf(u8, bodies.items[0], "\"hoverProvider\":true") != null); // Broken document: one diagnostic at line 1, character 2..8 ("hi")); const syntax = analyze(arena, "\n \n").?; try testing.expectEqualStrings("unknown element", unknown.message); try testing.expectEqual(@as(usize, 2), unknown.line); try testing.expectEqual(@as(usize, 3), unknown.column); // markdown misuse surfaces the validator's teaching messages. const missing_source = analyze(arena, "\n \n").?; try testing.expectEqualStrings(ui_markup.markdown_source_message, missing_source.message); try testing.expectEqual(@as(usize, 2), missing_source.line); try testing.expectEqual(@as(?ui_markup.MarkupErrorInfo, null), analyze(arena, "")); // A dead value handler on a non-hit-target element gets the teaching // diagnostic, positioned at the attribute; on-press is legal there (a // bound press handler makes any element pressable). const dead_handler = analyze(arena, "\n \n x\n \n").?; try testing.expectEqualStrings(ui_markup.non_hit_target_handler_message, dead_handler.message); try testing.expectEqual(@as(usize, 2), dead_handler.line); try testing.expectEqual(@as(usize, 8), dead_handler.column); try testing.expectEqual(@as(?ui_markup.MarkupErrorInfo, null), analyze(arena, "\n \n x\n \n")); try testing.expectEqual(@as(?ui_markup.MarkupErrorInfo, null), analyze(arena, "x")); // gap on a stacking container gets the teaching diagnostic at the // attribute; a flow container inside keeps its gap. const dead_gap = analyze(arena, "\n \n x\n \n").?; try testing.expectEqualStrings(ui_markup.stack_container_gap_message, dead_gap.message); try testing.expectEqual(@as(usize, 2), dead_gap.line); try testing.expectEqual(@as(usize, 10), dead_gap.column); try testing.expectEqual(@as(?ui_markup.MarkupErrorInfo, null), analyze(arena, "x")); // avatar image misuse surfaces the validator's teaching messages. const literal_image = analyze(arena, "\n CT\n").?; try testing.expectEqualStrings(ui_markup.avatar_image_message, literal_image.message); const misplaced_image = analyze(arena, "\n 3\n").?; try testing.expectEqualStrings(ui_markup.avatar_image_element_message, misplaced_image.message); try testing.expectEqual(@as(?ui_markup.MarkupErrorInfo, null), analyze(arena, "CT")); // The a11y lint's error half rides analyze (an unnamed control is // announced as an unnamed control - unusable blind). const unnamed_control = analyze(arena, "\n \n").?; try testing.expectEqualStrings(ui_markup.a11y_unlabeled_control_message, unnamed_control.message); try testing.expectEqual(@as(?ui_markup.MarkupErrorInfo, null), analyze(arena, "")); } test "analyzeWarnings reports the a11y lint's warning half at warning positions" { const allocator = testing.allocator; var arena_state = std.heap.ArenaAllocator.init(allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); var storage: [ui_markup.max_a11y_warnings]ui_markup.MarkupErrorInfo = undefined; // An unnamed image validates clean but warns; label="" (decorative) // and a real label stay quiet. const unnamed = analyzeWarnings(arena, "\n \n", &storage); try testing.expectEqual(@as(usize, 1), unnamed.len); try testing.expectEqualStrings(ui_markup.a11y_unlabeled_image_message, unnamed[0].message); try testing.expectEqual(@as(usize, 2), unnamed[0].line); try testing.expectEqual(@as(usize, 0), analyzeWarnings(arena, "", &storage).len); // A label duplicating the text it shadows warns at the label. const redundant = analyzeWarnings(arena, "\n \n", &storage); try testing.expectEqual(@as(usize, 1), redundant.len); try testing.expectEqualStrings(ui_markup.a11y_redundant_label_message, redundant[0].message); // Unparseable sources report nothing: the error diagnostic owns it. try testing.expectEqual(@as(usize, 0), analyzeWarnings(arena, "x", 6) == .none); const attrs = completionContext(""; const row = hoverAt(source, 2).?; try testing.expectEqualStrings("row", row.name); const gap = hoverAt(source, 5).?; try testing.expectEqualStrings("gap", gap.name); const on_press = hoverAt(source, 23).?; try testing.expectEqualStrings("on-press", on_press.name); // Text content has no hover; neither does a string value. try testing.expect(hoverAt(source, 37) == null); try testing.expect(hoverAt(source, 32) == null); // Closing tag name hovers like the opening one. const closing = hoverAt(source, 45).?; try testing.expectEqualStrings("button", closing.name); } test "doc tables cover every known element, attribute, and event" { for (ui_markup.known_element_names) |name| { try testing.expect(elementDoc(name) != null); } for ([_][]const u8{ "for", "if", "else", "template", "use", "import", "slot", "markdown", "stepper", "step", "timeline", "timeline-item", "chart", "series", "context-menu", "input-group", "input-group-actions", "span", "reactions" }) |name| { try testing.expect(elementDoc(name) != null); } for (ui_markup.schema.attrs) |entry| { // Every registry attribute — the composite sets included — has a // doc line for hover/completion. try testing.expect(attributeDoc(entry.name) != null); } for (ui_markup.known_option_attrs) |name| { try testing.expect(attributeDoc(name) != null); } for (ui_markup.known_color_style_attrs) |name| { try testing.expect(attributeDoc(name) != null); } for ([_][]const u8{ "radius", "name", "args", "template", "src" }) |name| { try testing.expect(attributeDoc(name) != null); } // The composite elements' closed attribute sets are documented. for ([_][]const u8{ "source", "on-link", "on-details", "details-expanded", "issue-link-base" }) |name| { try testing.expect(attributeDoc(name) != null); } for ([_][]const u8{ "active", "title", "description", "meta", "indicator", "connector", "image" }) |name| { try testing.expect(attributeDoc(name) != null); } for ([_][]const u8{ "values", "y-min", "y-max", "grid-lines", "baseline", "x-labels", "y-labels", "hover-details", "stroke-width", "color" }) |name| { try testing.expect(attributeDoc(name) != null); } for (ui_markup.known_events) |event| { var buffer: [32]u8 = undefined; const name = try std.fmt.bufPrint(&buffer, "on-{s}", .{event}); try testing.expect(attributeDoc(name) != null); } }