hover support, better diagnostics

This commit is contained in:
Emil Lerch 2026-09-01 14:48:22 -07:00
parent 9269bbc6ca
commit 35c484460a
Signed by: lobo
GPG key ID: A7B62D657EF764F8
8 changed files with 2172 additions and 156 deletions

View file

@ -10,16 +10,35 @@ Provides real-time parse error diagnostics using the SRF library's parser direct
directly: missing or duplicate `#!srfv1`, bad type hints, unparseable values,
data after `#!eof`, and so on.
- All errors from a parse are reported, not just the first.
- Length-prefix checking for the cases srf does not cover. A numeric type hint
declares an exact byte count. srf reports a wrong one in compact format, but in
long format it keeps the declared bytes and discards the rest of the line
without a word, and a count running past the end of the file fails with no
location at all. Both are detected here and reported on the offending bytes.
- Hover, covering every construct in the format:
- `#!srfv1` and each directive, explained.
- `#!expires`, `#!created` and `#!modified` render their Unix timestamp as a
UTC date, because nobody can read `1772589213`.
- Keys and values show the parsed interpretation: numbers, booleans, byte
counts for strings, and for `binary` values the decoded content when it is
text (`decoding to 5 bytes: "hello"`) or a note when it is not.
- Type hints are explained, including a numeric hint's length-prefix meaning.
- Position encoding is negotiated during `initialize` (`utf-8`, `utf-16` or
`utf-32`), so columns line up even on lines containing multi-byte characters.
- Full-document sync, which matches SRF's single-pass parser.
- stdio transport.
Not implemented: hover, completion, document symbols, formatting, goto
definition, semantic tokens, and pull diagnostics
(`textDocument/diagnostic`). Requests for them are answered with
`MethodNotFound` rather than left unanswered, and none of them are advertised as
server capabilities.
Value interpretation defers to the srf library rather than reimplementing its
rules, so hover and diagnostics cannot disagree about the same text.
Not implemented: completion, document symbols, formatting, goto definition,
semantic tokens, and pull diagnostics (`textDocument/diagnostic`). Requests for
them are answered with `MethodNotFound` rather than left unanswered, and none of
them are advertised as server capabilities.
## Requirements
Zig 0.16.0. Pinned in `.mise.toml`, along with the other tooling.
## Setup

View file

@ -5,8 +5,8 @@
.minimum_zig_version = "0.16.0",
.dependencies = .{
.srf = .{
.url = "git+https://git.lerch.org/lobo/srf#7692d85745a90144e358bac9f73d1090ddc125fe",
.hash = "srf-0.0.0-qZj5760tAgDN_2Y-nsXzkW_qbntB05Iio___ziFdi937",
.url = "git+https://git.lerch.org/lobo/srf#08f06df810684f8d32ad0ea5a8f2b3ee61733d2c",
.hash = "srf-0.0.0-qZj571YvAgCNHXtzQCOdoAKNzjhLp5o_Ap-GMFmO5-CW",
},
},
.paths = .{

View file

@ -13,6 +13,8 @@
const std = @import("std");
const analysis = @import("analysis.zig");
const hover = @import("hover.zig");
const positions = @import("positions.zig");
const rpc = @import("rpc.zig");
const log = std.log.scoped(.srf_lsp);
@ -98,6 +100,16 @@ fn field(object: std.json.ObjectMap, name: []const u8) ?std.json.Value {
return object.get(name);
}
/// LSP line and character numbers are unsigned. A negative or out-of-range value
/// is the client's bug, and clamping to 0 beats refusing the whole request.
fn asU32(value: ?std.json.Value) ?u32 {
const v = value orelse return null;
return switch (v) {
.integer => |i| if (i < 0) 0 else std.math.cast(u32, i) orelse std.math.maxInt(u32),
else => null,
};
}
//
// Sending
//
@ -210,6 +222,8 @@ fn dispatch(
return self.handleDidClose(root);
} else if (std.mem.eql(u8, method, "textDocument/didSave")) {
return; // full sync means didChange already told us everything
} else if (std.mem.eql(u8, method, "textDocument/hover")) {
return self.handleHover(root, id);
}
// Unknown method. A request must be told we cannot serve it; a notification
@ -242,6 +256,7 @@ fn handleInitialize(self: *Server, root: std.json.ObjectMap, id: ?std.json.Value
// parser is single-pass over the entire text.
.change = 1,
},
.hoverProvider = true,
// Deliberately no `diagnosticProvider`: that advertises *pull*
// diagnostics (`textDocument/diagnostic`), which we do not
// implement. We push via `textDocument/publishDiagnostics` instead,
@ -337,6 +352,42 @@ fn handleDidClose(self: *Server, root: std.json.ObjectMap) !void {
});
}
//
// Hover
//
fn handleHover(self: *Server, root: std.json.ObjectMap, id: ?std.json.Value) !void {
// A hover request with no id is malformed; there is nowhere to send a reply.
const request_id = id orelse return;
const params = asObject(field(root, "params")) orelse
return self.sendError(request_id, .invalid_params, "hover needs params");
const doc = asObject(field(params, "textDocument")) orelse
return self.sendError(request_id, .invalid_params, "hover needs a textDocument");
const uri = asString(field(doc, "uri")) orelse
return self.sendError(request_id, .invalid_params, "hover needs a document uri");
const position = asObject(field(params, "position")) orelse
return self.sendError(request_id, .invalid_params, "hover needs a position");
const text = self.documents.get(uri) orelse {
// Not an error: the client may ask about a document it never opened, or
// one we already closed. Null means "nothing to show here".
return self.sendResult(request_id, null);
};
const at: positions.Position = .{
.line = asU32(field(position, "line")) orelse 0,
.character = asU32(field(position, "character")) orelse 0,
};
const result = try hover.hoverAt(self.allocator, text, at, self.position_encoding) orelse {
return self.sendResult(request_id, null);
};
defer result.deinit(self.allocator);
try self.sendResult(request_id, result);
}
fn publishDiagnostics(self: *Server, uri: []const u8, text: []const u8) !void {
const diagnostics = try analysis.analyze(self.allocator, text, self.position_encoding);
defer analysis.freeDiagnostics(self.allocator, diagnostics);
@ -731,10 +782,105 @@ test "we do not advertise capabilities we cannot serve" {
// diagnostics in particular: announcing it makes conforming clients send
// textDocument/diagnostic, which we would only answer with -32601.
try h.expectNotSent("diagnosticProvider");
try h.expectNotSent("hoverProvider");
try h.expectNotSent("completionProvider");
try h.expectNotSent("documentSymbolProvider");
try h.expectNotSent("documentFormattingProvider");
try h.expectNotSent("definitionProvider");
try h.expectNotSent("semanticTokensProvider");
}
test "hover is advertised now that it is implemented" {
var h: Harness = undefined;
h.setup(testing.allocator);
defer h.deinit();
try h.initialize();
try h.expectSent("\"hoverProvider\":true");
}
test "hovering an open document returns markdown" {
var h: Harness = undefined;
h.setup(testing.allocator);
defer h.deinit();
try h.initialize();
try h.server.handleMessage(
\\{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"file:///x.srf","languageId":"srf","version":1,"text":"#!srfv1\nage:num:30\n"}}}
);
try h.server.handleMessage(
\\{"jsonrpc":"2.0","id":7,"method":"textDocument/hover","params":{"textDocument":{"uri":"file:///x.srf"},"position":{"line":1,"character":1}}}
);
try h.expectSent("\"id\":7");
try h.expectSent("markdown");
try h.expectSent("Parsed as a number");
}
test "hovering a directive renders its timestamp" {
var h: Harness = undefined;
h.setup(testing.allocator);
defer h.deinit();
try h.initialize();
try h.server.handleMessage(
\\{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"file:///x.srf","languageId":"srf","version":1,"text":"#!srfv1\n#!expires=1772589213\nk::v\n"}}}
);
try h.server.handleMessage(
\\{"jsonrpc":"2.0","id":8,"method":"textDocument/hover","params":{"textDocument":{"uri":"file:///x.srf"},"position":{"line":1,"character":4}}}
);
try h.expectSent("2026-03-04");
}
test "hovering blank space returns a null result, not an error" {
var h: Harness = undefined;
h.setup(testing.allocator);
defer h.deinit();
try h.initialize();
try h.server.handleMessage(
\\{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"file:///x.srf","languageId":"srf","version":1,"text":"#!srfv1\n\nk::v\n"}}}
);
try h.server.handleMessage(
\\{"jsonrpc":"2.0","id":9,"method":"textDocument/hover","params":{"textDocument":{"uri":"file:///x.srf"},"position":{"line":1,"character":0}}}
);
try h.expectSent("{\"jsonrpc\":\"2.0\",\"id\":9,\"result\":null}");
}
test "hovering a document we never opened returns null" {
var h: Harness = undefined;
h.setup(testing.allocator);
defer h.deinit();
try h.initialize();
try h.server.handleMessage(
\\{"jsonrpc":"2.0","id":10,"method":"textDocument/hover","params":{"textDocument":{"uri":"file:///never.srf"},"position":{"line":0,"character":0}}}
);
try h.expectSent("\"id\":10,\"result\":null");
}
test "a hover request missing its position is refused with invalid_params" {
var h: Harness = undefined;
h.setup(testing.allocator);
defer h.deinit();
try h.initialize();
try h.server.handleMessage(
\\{"jsonrpc":"2.0","id":11,"method":"textDocument/hover","params":{"textDocument":{"uri":"file:///x.srf"}}}
);
try h.expectSent("-32602");
}
test "a hover position with junk types does not take the server down" {
var h: Harness = undefined;
h.setup(testing.allocator);
defer h.deinit();
try h.initialize();
try h.server.handleMessage(
\\{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"file:///x.srf","languageId":"srf","version":1,"text":"#!srfv1\nk::v\n"}}}
);
try h.server.handleMessage(
\\{"jsonrpc":"2.0","id":12,"method":"textDocument/hover","params":{"textDocument":{"uri":"file:///x.srf"},"position":{"line":"nope","character":-5}}}
);
try testing.expectEqual(Phase.running, h.server.phase);
try h.expectSent("\"id\":12");
}

View file

@ -10,34 +10,12 @@
const std = @import("std");
const srf = @import("srf");
const document = @import("document.zig");
const positions = @import("positions.zig");
/// How LSP positions are counted. The client picks this during `initialize`.
pub const PositionEncoding = enum {
/// Byte offsets. What srf already reports, so no conversion.
@"utf-8",
/// UTF-16 code units. The protocol default, so it is the fallback.
@"utf-16",
/// Codepoints.
@"utf-32",
pub fn fromWireName(name: []const u8) ?PositionEncoding {
return std.meta.stringToEnum(PositionEncoding, name);
}
pub fn wireName(self: PositionEncoding) []const u8 {
return @tagName(self);
}
};
pub const Position = struct {
line: u32,
character: u32,
};
pub const Range = struct {
start: Position,
end: Position,
};
pub const PositionEncoding = positions.Encoding;
pub const Position = positions.Position;
pub const Range = positions.Range;
/// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#diagnosticSeverity
pub const Severity = enum(u8) {
@ -125,12 +103,12 @@ const Sink = struct {
else
0;
const line_text = lineAt(self.text, line_index);
const line_text = positions.lineAt(self.text, line_index);
const byte_col_raw = if (err.column > 0) err.column - 1 else 0;
const byte_col = @min(byte_col_raw, line_text.len);
const line_end = convertColumn(line_text, line_text.len, self.encoding);
const start = convertColumn(line_text, byte_col, self.encoding);
const line_end = positions.characterFromByte(line_text, line_text.len, self.encoding);
const start = positions.characterFromByte(line_text, byte_col, self.encoding);
// srf's column usually points just past the text it consumed, which for
// a whole-line problem lands at end of line. Underlining from there to
@ -159,62 +137,6 @@ fn severityFor(level: std.log.Level) Severity {
};
}
/// Returns the 0-based `index`th line of `text`, excluding its newline. Empty if
/// the line does not exist.
fn lineAt(text: []const u8, index: u32) []const u8 {
var remaining = text;
var i: u32 = 0;
while (true) {
const nl = std.mem.indexOfScalar(u8, remaining, '\n');
if (i == index) {
const line = if (nl) |n| remaining[0..n] else remaining;
return std.mem.trimEnd(u8, line, "\r");
}
if (nl) |n| {
remaining = remaining[n + 1 ..];
i += 1;
} else {
return "";
}
}
}
/// Converts a byte offset within a line to a character offset in `encoding`.
/// Invalid UTF-8 is counted a byte at a time rather than rejected, so a
/// half-typed multi-byte character still produces a usable position.
fn convertColumn(line: []const u8, byte_offset: usize, encoding: PositionEncoding) u32 {
const limit = @min(byte_offset, line.len);
if (encoding == .@"utf-8") return @intCast(limit);
var count: u32 = 0;
var i: usize = 0;
while (i < limit) {
const len = std.unicode.utf8ByteSequenceLength(line[i]) catch {
i += 1;
count += 1;
continue;
};
if (i + len > line.len) {
i += 1;
count += 1;
continue;
}
const codepoint = std.unicode.utf8Decode(line[i..][0..len]) catch {
i += 1;
count += 1;
continue;
};
count += switch (encoding) {
// Anything outside the BMP takes two UTF-16 code units.
.@"utf-16" => if (codepoint > 0xFFFF) 2 else 1,
.@"utf-32" => 1,
.@"utf-8" => unreachable,
};
i += len;
}
return count;
}
/// Parses `text` and returns its diagnostics. Caller owns the result and should
/// release it with `freeDiagnostics`.
pub fn analyze(
@ -234,14 +156,25 @@ pub fn analyze(
}
var diagnostics = sink.interface();
run(allocator, text, &diagnostics) catch |err| {
// srf can fail without saying why: `iterator` returns `ParseFailed` with
// no diagnostic at all for an empty document. Swallowing that leaves the
// editor showing nothing on a file that will not parse, so synthesise a
// diagnostic rather than drop the reason on the floor.
if (sink.items.items.len == 0) try appendUnlocatedError(allocator, &sink, text, err);
const parse_error: ?anyerror = blk: {
run(allocator, text, &diagnostics) catch |err| break :blk err;
break :blk null;
};
// Our own checks run before the fallback below, because they can often name
// the exact problem srf failed to locate. `srf.zig:873` is the common case:
// it detects a length-prefixed value that misses its delimiter, logs it, and
// returns a bare `ParseFailed` with no diagnostic at all.
try appendLengthProblems(allocator, &sink, text, encoding);
if (parse_error) |err| {
// Only if nothing at all was found. srf can fail without saying why:
// `iterator` returns `ParseFailed` with no diagnostic for an empty
// document. Swallowing that would leave the editor showing nothing on a
// file that will not parse.
if (sink.items.items.len == 0) try appendUnlocatedError(allocator, &sink, text, err);
}
if (sink.dropped) {
// Best effort: if we could not allocate a diagnostic we probably cannot
// allocate this note either, and the list we have is still worth
@ -258,6 +191,84 @@ pub fn analyze(
return sink.items.toOwnedSlice(allocator);
}
/// Reports length-prefix declarations that srf does not usefully report itself.
///
/// srf covers the compact-format case as of 08f06df: a value that does not end on
/// the delimiter gets a diagnostic and a fatal `ParseFailed`. We stay out of its
/// way there, because two squiggles for one defect is worse than one.
///
/// What is left to us:
///
/// * **Long format overruns.** `srf.zig:848` takes the `field_delimiter == '\n'`
/// branch and calls `nextLine()` unconditionally, discarding whatever was left
/// of the line. No diagnostic, no failure, no log: `k:3:hello` silently
/// becomes `hel`. Silent data loss is the worst kind, so we report it.
/// * **Declarations past the end of the file.** srf fails with a bare
/// `EndOfStream` and no location at all.
fn appendLengthProblems(
allocator: std.mem.Allocator,
sink: *Sink,
text: []const u8,
encoding: PositionEncoding,
) error{OutOfMemory}!void {
var it = document.items(text);
while (it.next()) |item| {
const located = switch (item) {
.field => |f| f,
else => continue,
};
const problem = located.field.length_problem orelse continue;
// srf already diagnoses a compact-format overrun, and does it fatally.
if (problem == .overrun and !located.field.long_mode) continue;
const message = switch (problem) {
.truncated => |t| try std.fmt.allocPrint(
allocator,
"length prefix declares {d} bytes but only {d} remain in the file",
.{ t.declared, t.available },
),
.overrun => |o| try std.fmt.allocPrint(
allocator,
"length prefix declares {d} bytes, so srf will silently discard the {d} byte(s) highlighted here",
.{ o.declared, o.surplus },
),
};
errdefer allocator.free(message);
sink.items.append(allocator, .{
// Underline what is wrong. For an overrun that is the surplus text
// past the declared end, not the bytes that do fit: highlighting the
// part srf keeps would point at the one bit that is correct.
.range = switch (problem) {
.truncated => positions.rangeFromSpan(
text,
located.value_span.start,
located.value_span.end,
encoding,
),
.overrun => |o| positions.rangeFromSpan(
text,
o.surplus_start,
o.surplus_start + o.surplus,
encoding,
),
},
// A long-format overrun still parses, so the cost is losing data
// rather than failing to read the file. A truncated declaration stops
// the parse outright.
.severity = switch (problem) {
.truncated => .err,
.overrun => .warning,
},
.message = message,
}) catch {
allocator.free(message);
return error.OutOfMemory;
};
}
}
/// Records a parse failure that the parser gave us no location for. Keeps the
/// underlying error name in the message: it is the only information we have
/// about why the parse stopped, so hiding it would leave the user guessing.
@ -278,11 +289,11 @@ fn appendUnlocatedError(
);
errdefer allocator.free(message);
const first_line = lineAt(text, 0);
const first_line = positions.lineAt(text, 0);
try sink.items.append(allocator, .{
.range = .{
.start = .{ .line = 0, .character = 0 },
.end = .{ .line = 0, .character = convertColumn(first_line, first_line.len, sink.encoding) },
.end = .{ .line = 0, .character = positions.characterFromByte(first_line, first_line.len, sink.encoding) },
},
.severity = .err,
.message = message,
@ -311,6 +322,25 @@ fn analyzeForTest(text: []const u8) ![]Diagnostic {
return analyze(testing.allocator, text, .@"utf-8");
}
/// Exercises only our own length checking, without running srf's parser.
///
/// Useful for asserting exactly what we contribute, separately from whatever srf
/// reports for the same input.
fn lengthDiagnosticsForTest(text: []const u8) ![]Diagnostic {
var sink: Sink = .{
.allocator = testing.allocator,
.text = text,
.encoding = .@"utf-8",
.items = .empty,
};
errdefer {
for (sink.items.items) |d| d.deinit(testing.allocator);
sink.items.deinit(testing.allocator);
}
try appendLengthProblems(testing.allocator, &sink, text, .@"utf-8");
return sink.items.toOwnedSlice(testing.allocator);
}
test "valid document has no diagnostics" {
const diags = try analyzeForTest("#!srfv1\nname::alice\nage:num:30\n");
defer freeDiagnostics(testing.allocator, diags);
@ -355,7 +385,7 @@ test "diagnostic range stays inside the line it names" {
defer freeDiagnostics(testing.allocator, diags);
try testing.expect(diags.len > 0);
for (diags) |d| {
const line = lineAt(text, d.range.start.line);
const line = positions.lineAt(text, d.range.start.line);
try testing.expect(d.range.start.character <= line.len);
try testing.expect(d.range.end.character <= line.len);
try testing.expect(d.range.end.character >= d.range.start.character);
@ -384,56 +414,6 @@ test "more errors than the old bounded buffer held are all reported" {
try testing.expect(diags.len > 10);
}
test "utf-8 columns are byte offsets" {
// "¥" is 2 bytes, 1 UTF-16 unit, 1 codepoint.
const line = "a¥b";
try testing.expectEqual(@as(u32, 4), convertColumn(line, line.len, .@"utf-8"));
}
test "utf-16 columns count code units" {
const line = "a¥b";
try testing.expectEqual(@as(u32, 3), convertColumn(line, line.len, .@"utf-16"));
}
test "utf-16 counts astral characters as surrogate pairs" {
const line = "a\u{1F600}b"; // emoji: 4 bytes, 2 UTF-16 units, 1 codepoint
try testing.expectEqual(@as(u32, 4), convertColumn(line, line.len, .@"utf-16"));
try testing.expectEqual(@as(u32, 3), convertColumn(line, line.len, .@"utf-32"));
try testing.expectEqual(@as(u32, 6), convertColumn(line, line.len, .@"utf-8"));
}
test "column conversion tolerates invalid utf-8" {
const line = "a\xffb";
try testing.expectEqual(@as(u32, 3), convertColumn(line, line.len, .@"utf-16"));
}
test "column conversion tolerates a truncated multi-byte sequence" {
const line = "a\xc2"; // leading byte of a 2-byte sequence, cut off
try testing.expectEqual(@as(u32, 2), convertColumn(line, line.len, .@"utf-16"));
}
test "column conversion clamps past the end of the line" {
const line = "abc";
try testing.expectEqual(@as(u32, 3), convertColumn(line, 99, .@"utf-16"));
}
test "lineAt splits on newlines and strips carriage returns" {
const text = "one\r\ntwo\nthree";
try testing.expectEqualStrings("one", lineAt(text, 0));
try testing.expectEqualStrings("two", lineAt(text, 1));
try testing.expectEqualStrings("three", lineAt(text, 2));
try testing.expectEqualStrings("", lineAt(text, 3));
try testing.expectEqualStrings("", lineAt(text, 99));
}
test "position encoding round trips through its wire name" {
try testing.expectEqual(PositionEncoding.@"utf-8", PositionEncoding.fromWireName("utf-8").?);
try testing.expectEqual(PositionEncoding.@"utf-16", PositionEncoding.fromWireName("utf-16").?);
try testing.expectEqual(PositionEncoding.@"utf-32", PositionEncoding.fromWireName("utf-32").?);
try testing.expectEqual(@as(?PositionEncoding, null), PositionEncoding.fromWireName("utf-7"));
try testing.expectEqualStrings("utf-16", PositionEncoding.@"utf-16".wireName());
}
test "srf BoundedDiagnostics.errors() returns a stable view" {
// This used to be the bug that made the server segfault: `errors()` took
// `self` by value, so the returned slice pointed into a copy that died with
@ -489,3 +469,127 @@ test "an error reported at end of line underlines the whole line" {
try testing.expectEqual(@as(u32, 0), range.start.character);
try testing.expectEqual(@as(u32, "broken line here".len), range.end.character);
}
test "a long-format length overrun is reported, which srf does not do" {
// srf keeps "hel" and drops "lo" without a word. That is silent data loss,
// so we report it ourselves.
const diags = try analyzeForTest("#!srfv1\n#!long\nk:3:hello\n");
defer freeDiagnostics(testing.allocator, diags);
try testing.expectEqual(@as(usize, 1), diags.len);
try testing.expectEqual(Severity.warning, diags[0].severity);
try testing.expect(std.mem.indexOf(u8, diags[0].message, "declares 3 bytes") != null);
try testing.expect(std.mem.indexOf(u8, diags[0].message, "silently discard the 2 byte(s)") != null);
try testing.expectEqual(@as(u32, 2), diags[0].range.start.line);
}
test "a length prefix past the end of the file is an error" {
const diags = try lengthDiagnosticsForTest("#!srfv1\n#!long\nk:99:hello\n");
defer freeDiagnostics(testing.allocator, diags);
try testing.expectEqual(@as(usize, 1), diags.len);
try testing.expectEqual(Severity.err, diags[0].severity);
try testing.expect(std.mem.indexOf(u8, diags[0].message, "declares 99 bytes but only 6 remain") != null);
}
test "a compact-format overrun is left to srf, which now reports it" {
// srf 08f06df diagnoses this fatally, so we add nothing: one defect, one
// diagnostic.
const ours = try lengthDiagnosticsForTest("#!srfv1\nk:2:abc\n");
defer freeDiagnostics(testing.allocator, ours);
try testing.expectEqual(@as(usize, 0), ours.len);
const all = try analyzeForTest("#!srfv1\nk:2:abc\n");
defer freeDiagnostics(testing.allocator, all);
try testing.expectEqual(@as(usize, 1), all.len);
try testing.expectEqual(Severity.err, all[0].severity);
// srf's own wording, located on the offending line.
try testing.expect(std.mem.indexOf(u8, all[0].message, "reset line for next item") != null);
try testing.expectEqual(@as(u32, 1), all[0].range.start.line);
}
test "a correct length prefix produces no diagnostic" {
const diags = try analyzeForTest("#!srfv1\n#!long\nk:5:hello\n");
defer freeDiagnostics(testing.allocator, diags);
try testing.expectEqual(@as(usize, 0), diags.len);
}
test "a correct length prefix with commas in compact format produces no diagnostic" {
const diags = try analyzeForTest("#!srfv1\nk:5:a,b,c,next::x\n");
defer freeDiagnostics(testing.allocator, diags);
try testing.expectEqual(@as(usize, 0), diags.len);
}
test "a multi-line length-prefixed value produces no diagnostic" {
const diags = try analyzeForTest("#!srfv1\n#!long\nbio:7:foo\nbar\nname::alice\n");
defer freeDiagnostics(testing.allocator, diags);
try testing.expectEqual(@as(usize, 0), diags.len);
}
test "an overrun diagnostic underlines the surplus, not the bytes that fit" {
// "k:3:hello": the value starts at character 4 and declares 3 bytes, so the
// surplus "lo" runs from character 7 to 9. Highlighting 4..7 would point at
// the one part of the line that is correct.
const text = "#!srfv1\n#!long\nk:3:hello\n";
const diags = try analyzeForTest(text);
defer freeDiagnostics(testing.allocator, diags);
try testing.expectEqual(@as(usize, 1), diags.len);
try testing.expectEqual(@as(u32, 7), diags[0].range.start.character);
try testing.expectEqual(@as(u32, 9), diags[0].range.end.character);
}
test "a truncation diagnostic underlines the value it does have" {
const diags = try lengthDiagnosticsForTest("#!srfv1\n#!long\nk:99:hello\n");
defer freeDiagnostics(testing.allocator, diags);
try testing.expectEqual(@as(u32, 5), diags[0].range.start.character);
}
test "length diagnostics coexist with srf's own diagnostics" {
// Duplicate magic header (srf's finding) plus a bad length prefix (ours).
const diags = try analyzeForTest("#!srfv1\n#!srfv1\n#!long\nk:3:hello\n");
defer freeDiagnostics(testing.allocator, diags);
var from_srf = false;
var from_us = false;
for (diags) |d| {
if (std.mem.indexOf(u8, d.message, "duplicate magic") != null) from_srf = true;
if (std.mem.indexOf(u8, d.message, "length prefix") != null) from_us = true;
}
try testing.expect(from_srf);
try testing.expect(from_us);
}
test "a compact overrun no longer produces the unlocated fallback" {
// srf now fails fatally *with* a diagnostic, so the generic
// "reported no location" message must not appear alongside it.
const diags = try analyzeForTest("#!srfv1\nk:2:abc\n");
defer freeDiagnostics(testing.allocator, diags);
for (diags) |d| {
try testing.expect(std.mem.indexOf(u8, d.message, "reported no location") == null);
}
}
test "a located length problem replaces the unlocated fallback" {
// srf fails here with a bare EndOfStream and no diagnostic, so without our
// own check the user would get "parsing stopped ... no location". A precise
// message on the offending line is strictly better, and the generic one
// should not tag along beside it.
//
// The truncated form is used rather than an overrun because an overrun makes
// srf log at error level (srf.zig:873), and Zig's test runner counts any
// error log as a failed test. See `lengthDiagnosticsForTest`.
const diags = try analyzeForTest("#!srfv1\n#!long\nk:99:hello\n");
defer freeDiagnostics(testing.allocator, diags);
for (diags) |d| {
if (std.mem.indexOf(u8, d.message, "reported no location") != null) {
std.debug.print("\nunlocated fallback should have been suppressed\n", .{});
return error.UnexpectedFallback;
}
}
try testing.expectEqual(@as(usize, 1), diags.len);
try testing.expect(std.mem.indexOf(u8, diags[0].message, "declares 99 bytes but only 6 remain") != null);
}
test "the unlocated fallback still fires when nothing else explains the failure" {
const diags = try analyzeForTest("");
defer freeDiagnostics(testing.allocator, diags);
try testing.expectEqual(@as(usize, 1), diags.len);
try testing.expect(std.mem.indexOf(u8, diags[0].message, "empty") != null);
}

767
src/document.zig Normal file
View file

@ -0,0 +1,767 @@
//! Locates the SRF construct under a byte offset.
//!
//! srf's parser is a streaming iterator and `srf.Field` carries no positions, so
//! answering "what is under the cursor?" needs its own pass. This module does
//! only that: it finds a token and hands back the surrounding field's text.
//! Interpreting the value is `hover.zig`'s job, and it defers to srf for that.
//!
//! The scan mirrors the format's two mode-dependent rules, which is why it
//! cannot be a simple line splitter:
//!
//! * the field delimiter is `,` in compact format and end-of-line after
//! `#!long`, so what counts as one field depends on a directive above it;
//! * a numeric type hint means "the value is exactly N bytes", which can carry
//! a value across newlines.
const std = @import("std");
pub const Span = struct {
start: usize,
end: usize,
fn contains(self: Span, offset: usize) bool {
// `end` is inclusive here: hovering with the cursor just past the last
// character of a word is still hovering that word, and editors routinely
// report exactly that position.
return offset >= self.start and offset <= self.end;
}
};
/// The field a token belongs to, as raw slices of the document.
pub const Field = struct {
key: []const u8,
/// null for the `key::value` form, which carries no hint.
hint: ?[]const u8,
/// Empty when the field has no value (`missing:null:`).
value: []const u8,
/// Whether the field was found under `#!long`.
long_mode: bool,
/// Set when a numeric hint's byte count does not agree with the document.
length_problem: ?LengthProblem = null,
/// The hint parsed as a byte count, for the length-prefixed form.
pub fn declaredLength(self: Field) ?usize {
const hint = self.hint orelse return null;
if (hint.len == 0) return null;
for (hint) |c| if (c < '0' or c > '9') return null;
return std.fmt.parseInt(usize, hint, 10) catch null;
}
};
pub const Directive = struct {
/// Directive name without the `#!`, e.g. `long` or `expires`.
name: []const u8,
/// Text after `=`, if any.
argument: ?[]const u8,
};
/// A length prefix that does not agree with the document.
///
/// Worth reporting separately because srf handles these badly: in long format it
/// silently keeps the declared bytes and drops the rest of the line, and for a
/// declaration past the end of the file it fails with no location at all.
pub const LengthProblem = union(enum) {
/// The document ran out before the declared byte count.
truncated: struct { declared: usize, available: usize },
/// The declared count does not land on a field terminator, so the value
/// really continues past where the declaration says it stops. In long format
/// srf discards the remainder without a word.
overrun: struct {
declared: usize,
/// Where the surplus starts, which is the declared end of the value.
surplus_start: usize,
/// Bytes from there to the end of that line: the text that does not fit
/// the declaration, and which srf drops in long format.
surplus: usize,
},
};
/// A field and the spans of its parts.
pub const Located = struct {
field: Field,
key_span: Span,
/// null for the `key::value` form.
hint_span: ?Span,
value_span: Span,
};
pub const Item = union(enum) {
/// The `#!srfv1` header.
magic: Span,
directive: struct { span: Span, value: Directive },
field: Located,
};
pub const Token = struct {
span: Span,
what: What,
};
pub const What = union(enum) {
magic,
directive: Directive,
key: Field,
hint: Field,
value: Field,
};
/// Walks every construct in the document in order. Comments and blank lines are
/// skipped: nothing in them is addressable.
pub fn items(text: []const u8) Iterator {
return .{ .scanner = .{ .text = text } };
}
pub const Iterator = struct {
scanner: Scanner,
pub fn next(self: *Iterator) ?Item {
return self.scanner.next();
}
};
/// Returns the token covering `offset`, or null if the offset lands on a comment,
/// a blank line, punctuation or past the end.
pub fn tokenAt(text: []const u8, offset: usize) ?Token {
var it = items(text);
while (it.next()) |item| {
switch (item) {
.magic => |span| {
if (span.contains(offset)) return .{ .span = span, .what = .magic };
},
.directive => |d| {
if (d.span.contains(offset)) {
return .{ .span = d.span, .what = .{ .directive = d.value } };
}
},
.field => |located| {
if (located.key_span.contains(offset)) {
return .{ .span = located.key_span, .what = .{ .key = located.field } };
}
if (located.hint_span) |hs| {
if (hs.contains(offset)) {
return .{ .span = hs, .what = .{ .hint = located.field } };
}
}
// An empty value has nothing to point at.
if (located.value_span.end > located.value_span.start and
located.value_span.contains(offset))
{
return .{ .span = located.value_span, .what = .{ .value = located.field } };
}
},
}
}
return null;
}
const magic_header = "#!srfv1";
/// One forward pass over the document, yielding one construct per call.
///
/// It cannot be a line splitter, because two of the format's rules are not local
/// to a line: the field delimiter depends on a `#!long` / `#!compact` directive
/// above, and a numeric type hint means "exactly N bytes", which can carry a
/// value across newlines.
const Scanner = struct {
text: []const u8,
/// Start of the next line to classify.
pos: usize = 0,
long_mode: bool = false,
/// Set while part way through a field line. Compact format packs several
/// fields onto one line, so the scan has to be able to resume mid-line.
field_cursor: ?usize = null,
/// End of the logical line being walked. A length-prefixed value can move it.
line_end: usize = 0,
fn next(self: *Scanner) ?Item {
// Finish the line we are part way through before reading another.
if (self.field_cursor) |cursor| return self.scanField(cursor);
while (self.pos < self.text.len) {
const line_start = self.pos;
const end = self.lineEnd(line_start);
// Leading whitespace is insignificant; srf trims it before deciding
// what a line is.
var body = line_start;
while (body < end and isSpace(self.text[body])) body += 1;
if (body == end) { // blank line
self.pos = self.nextLine(end);
continue;
}
if (self.text[body] == '#') {
const item = self.classifyHash(body, end);
self.pos = self.nextLine(end);
if (item) |i| return i;
continue; // a plain comment
}
self.line_end = end;
return self.scanField(body);
}
return null;
}
/// `#!srfv1`, a `#!directive`, or null for a plain comment.
fn classifyHash(self: *Scanner, body: usize, line_end: usize) ?Item {
const line = self.text[body..line_end];
if (!std.mem.startsWith(u8, line, "#!")) return null;
if (std.mem.startsWith(u8, line, magic_header)) {
return .{ .magic = .{ .start = body, .end = body + magic_header.len } };
}
// `#!name` or `#!name=argument`, stopping at an inline comment.
var end = body + 2;
while (end < line_end and !isSpace(self.text[end])) end += 1;
const decl = self.text[body + 2 .. end];
const eq = std.mem.indexOfScalar(u8, decl, '=');
const name = if (eq) |i| decl[0..i] else decl;
// Mode directives take effect for everything below them.
if (std.mem.eql(u8, name, "long")) self.long_mode = true;
if (std.mem.eql(u8, name, "compact")) self.long_mode = false;
return .{ .directive = .{
.span = .{ .start = body, .end = end },
.value = .{ .name = name, .argument = if (eq) |i| decl[i + 1 ..] else null },
} };
}
/// Reads one field starting at `field_start`, leaving `field_cursor` set if
/// another field follows it on the same line.
fn scanField(self: *Scanner, field_start: usize) ?Item {
// Key runs to the first ':'.
const colon = std.mem.indexOfScalarPos(u8, self.text[0..self.line_end], field_start, ':') orelse
return self.abandonLine();
const head = self.splitHead(colon) orelse return self.abandonLine();
const extent = self.valueExtent(head.hint, head.value_start);
// A length-prefixed value can swallow newlines, moving the end of the
// logical line being walked.
self.line_end = extent.line_end;
if (extent.next_field) |n| {
self.field_cursor = n;
} else {
self.field_cursor = null;
self.pos = self.nextLine(self.line_end);
}
return .{ .field = .{
.field = .{
.key = self.text[field_start..colon],
.hint = head.hint,
.value = self.text[head.value_start..extent.end],
.long_mode = self.long_mode,
.length_problem = extent.length_problem,
},
.key_span = .{ .start = field_start, .end = colon },
.hint_span = head.hint_span,
.value_span = .{ .start = head.value_start, .end = extent.end },
} };
}
/// Gives up on the current line: it has no colon, so it is not a field.
fn abandonLine(self: *Scanner) ?Item {
self.field_cursor = null;
self.pos = self.nextLine(self.line_end);
return self.next();
}
const Head = struct {
hint: ?[]const u8,
hint_span: ?Span,
value_start: usize,
};
/// Splits what follows the key's colon into an optional type hint and the
/// start of the value. Null when the line has no second colon, so it is not
/// a field at all.
fn splitHead(self: *const Scanner, colon: usize) ?Head {
if (colon + 1 < self.line_end and self.text[colon + 1] == ':') {
// `key::value` carries no hint.
return .{ .hint = null, .hint_span = null, .value_start = colon + 2 };
}
// `key:hint:value`, where the hint may be padded with whitespace.
const second = std.mem.indexOfScalarPos(u8, self.text[0..self.line_end], colon + 1, ':') orelse
return null;
var start = colon + 1;
var end = second;
while (start < end and isSpace(self.text[start])) start += 1;
while (end > start and isSpace(self.text[end - 1])) end -= 1;
return .{
.hint = self.text[start..end],
.hint_span = .{ .start = start, .end = end },
.value_start = second + 1,
};
}
const Extent = struct {
/// One past the last byte of the value.
end: usize,
/// Where the next field on this line starts, if any.
next_field: ?usize,
/// End of the logical line, which a length-prefixed value can move.
line_end: usize,
length_problem: ?LengthProblem,
};
/// How far the value runs, which is the one part of the format that depends
/// on both the mode and the type hint.
fn valueExtent(self: *const Scanner, hint: ?[]const u8, value_start: usize) Extent {
if (parseLength(hint)) |declared| {
// Exactly N bytes, newlines included. Clamped so a wrong declaration
// cannot run us off the end of the document.
const end = @min(value_start + declared, self.text.len);
const new_line_end = self.lineEnd(end);
const comma_follows = !self.long_mode and end < new_line_end and self.text[end] == ',';
return .{
.end = end,
.next_field = if (comma_follows) end + 1 else null,
.line_end = new_line_end,
.length_problem = self.lengthProblem(declared, value_start),
};
}
// Long format: the value runs to end of line and commas are literal text.
if (self.long_mode) {
return .{
.end = self.line_end,
.next_field = null,
.line_end = self.line_end,
.length_problem = null,
};
}
// Compact format: the next comma ends the value and starts a field.
if (std.mem.indexOfScalarPos(u8, self.text[0..self.line_end], value_start, ',')) |comma| {
return .{
.end = comma,
.next_field = comma + 1,
.line_end = self.line_end,
.length_problem = null,
};
}
return .{
.end = self.line_end,
.next_field = null,
.line_end = self.line_end,
.length_problem = null,
};
}
/// Checks a declared byte count against the document.
///
/// The test is what *follows* the declared bytes, not how many bytes we took:
/// a length-prefixed value must be followed immediately by a field
/// terminator. Comparing the count against a value sliced using that same
/// count would always agree, which is no test at all.
fn lengthProblem(self: *const Scanner, declared: usize, value_start: usize) ?LengthProblem {
const available = self.text.len - value_start;
if (available < declared) {
return .{ .truncated = .{ .declared = declared, .available = available } };
}
const after = value_start + declared;
// End of file terminates a field just as well as a newline does.
if (after >= self.text.len) return null;
const terminator = self.text[after];
if (terminator == '\n') return null;
if (!self.long_mode and terminator == ',') return null;
// Measured from the declared end, not from the start of the value: a
// multi-line length-prefixed value can put the surplus on a later line,
// and a range built from the first line's end would come out inverted.
return .{ .overrun = .{
.declared = declared,
.surplus_start = after,
.surplus = self.lineEnd(after) - after,
} };
}
fn lineEnd(self: *const Scanner, from: usize) usize {
return std.mem.indexOfScalarPos(u8, self.text, from, '\n') orelse self.text.len;
}
fn nextLine(self: *const Scanner, line_end: usize) usize {
return if (line_end < self.text.len) line_end + 1 else self.text.len;
}
};
fn isSpace(c: u8) bool {
return c == ' ' or c == '\t' or c == '\r';
}
/// The hint as a byte count, or null when it is a keyword hint.
fn parseLength(hint: ?[]const u8) ?usize {
const h = hint orelse return null;
if (h.len == 0) return null;
for (h) |c| if (c < '0' or c > '9') return null;
const n = std.fmt.parseInt(usize, h, 10) catch return null;
// A zero length is an empty value, not a span to walk.
return if (n == 0) null else n;
}
const testing = std.testing;
/// Finds `needle` in `text` and returns the offset of its midpoint, which is a
/// stable way to say "hover here" without hand-counting byte offsets.
fn midpointOf(text: []const u8, needle: []const u8) usize {
const at = std.mem.indexOf(u8, text, needle).?;
return at + needle.len / 2;
}
test "hovering the magic header" {
const text = "#!srfv1\nname::alice\n";
const token = tokenAt(text, midpointOf(text, "#!srfv1")).?;
try testing.expect(token.what == .magic);
try testing.expectEqualStrings("#!srfv1", text[token.span.start..token.span.end]);
}
test "hovering a bare directive" {
const text = "#!srfv1\n#!long\nname::alice\n";
const token = tokenAt(text, midpointOf(text, "#!long")).?;
try testing.expectEqualStrings("long", token.what.directive.name);
try testing.expectEqual(@as(?[]const u8, null), token.what.directive.argument);
}
test "hovering a directive with an argument" {
const text = "#!srfv1\n#!expires=1772589213\nk::v\n";
const token = tokenAt(text, midpointOf(text, "expires")).?;
try testing.expectEqualStrings("expires", token.what.directive.name);
try testing.expectEqualStrings("1772589213", token.what.directive.argument.?);
}
test "a directive's inline comment is not part of its argument" {
const text = "#!srfv1\n#!long # use long format\nk::v\n";
const token = tokenAt(text, midpointOf(text, "#!long")).?;
try testing.expectEqualStrings("long", token.what.directive.name);
try testing.expectEqualStrings("#!long", text[token.span.start..token.span.end]);
}
test "a plain comment has nothing to hover" {
const text = "#!srfv1\n# just a comment\nk::v\n";
try testing.expectEqual(@as(?Token, null), tokenAt(text, midpointOf(text, "just")));
}
test "hovering an untyped key" {
const text = "#!srfv1\nname::alice\n";
const token = tokenAt(text, midpointOf(text, "name")).?;
try testing.expectEqualStrings("name", token.what.key.key);
try testing.expectEqual(@as(?[]const u8, null), token.what.key.hint);
try testing.expectEqualStrings("alice", token.what.key.value);
}
test "hovering a typed key, its hint and its value" {
const text = "#!srfv1\nage:num:30\n";
const key = tokenAt(text, midpointOf(text, "age")).?;
try testing.expectEqualStrings("age", key.what.key.key);
try testing.expectEqualStrings("num", key.what.key.hint.?);
const hint = tokenAt(text, midpointOf(text, "num")).?;
try testing.expect(hint.what == .hint);
try testing.expectEqualStrings("num", text[hint.span.start..hint.span.end]);
const value = tokenAt(text, midpointOf(text, "30")).?;
try testing.expect(value.what == .value);
try testing.expectEqualStrings("30", value.what.value.value);
}
test "whitespace around a hint is not part of it" {
const text = "#!srfv1\nage: num :30\n";
const token = tokenAt(text, midpointOf(text, "num")).?;
try testing.expectEqualStrings("num", text[token.span.start..token.span.end]);
}
test "compact format splits fields on commas" {
const text = "#!srfv1\nname::alice,age:num:30\n";
const first = tokenAt(text, midpointOf(text, "alice")).?;
try testing.expectEqualStrings("alice", first.what.value.value);
try testing.expectEqualStrings("name", first.what.value.key);
const second = tokenAt(text, midpointOf(text, "age")).?;
try testing.expectEqualStrings("age", second.what.key.key);
try testing.expectEqualStrings("30", second.what.key.value);
}
test "long format keeps commas inside the value" {
const text = "#!srfv1\n#!long\nname::alice, bob and carol\n";
const token = tokenAt(text, midpointOf(text, "bob")).?;
try testing.expectEqualStrings("alice, bob and carol", token.what.value.value);
try testing.expect(token.what.value.long_mode);
}
test "a compact directive switches back from long" {
const text = "#!srfv1\n#!long\n#!compact\nname::alice,age:num:30\n";
const token = tokenAt(text, midpointOf(text, "alice")).?;
try testing.expectEqualStrings("alice", token.what.value.value);
try testing.expect(!token.what.value.long_mode);
}
test "a length-prefixed value spans newlines" {
const text = "#!srfv1\n#!long\nbio:7:foo\nbar\nname::alice\n";
const token = tokenAt(text, midpointOf(text, "foo")).?;
try testing.expect(token.what == .value);
try testing.expectEqualStrings("foo\nbar", token.what.value.value);
try testing.expectEqual(@as(?usize, 7), token.what.value.declaredLength());
}
test "the field after a length-prefixed value is still found" {
const text = "#!srfv1\n#!long\nbio:7:foo\nbar\nname::alice\n";
const token = tokenAt(text, midpointOf(text, "alice")).?;
try testing.expectEqualStrings("name", token.what.value.key);
try testing.expectEqualStrings("alice", token.what.value.value);
}
test "a length-prefixed value in compact format is followed by a comma" {
const text = "#!srfv1\nk:5:a,b,c,next::x\n";
const value = tokenAt(text, midpointOf(text, "a,b,c")).?;
try testing.expectEqualStrings("a,b,c", value.what.value.value);
const next = tokenAt(text, midpointOf(text, "next")).?;
try testing.expectEqualStrings("next", next.what.key.key);
try testing.expectEqualStrings("x", next.what.key.value);
}
test "a length longer than the document does not run off the end" {
const text = "#!srfv1\n#!long\nk:9999:short\n";
const token = tokenAt(text, midpointOf(text, "short")).?;
try testing.expectEqualStrings("short\n", token.what.value.value);
}
test "indented fields are found" {
const text = "#!srfv1\n#!long\n name::alice\n";
const token = tokenAt(text, midpointOf(text, "name")).?;
try testing.expectEqualStrings("name", token.what.key.key);
}
test "a key containing a comma is still one key" {
const text = "#!srfv1\n#!long\nlast, first::alice\n";
const token = tokenAt(text, midpointOf(text, "first")).?;
try testing.expectEqualStrings("last, first", token.what.key.key);
}
test "an empty value yields no value token" {
const text = "#!srfv1\n#!long\nmissing:null:\n";
const key = tokenAt(text, midpointOf(text, "missing")).?;
try testing.expectEqualStrings("", key.what.key.value);
// The colon itself is not a value.
try testing.expectEqual(@as(?Token, null), tokenAt(text, text.len - 1));
}
test "a line with no colon has nothing to hover" {
const text = "#!srfv1\n#!long\ngarbage line\nk::v\n";
try testing.expectEqual(@as(?Token, null), tokenAt(text, midpointOf(text, "garbage")));
}
test "a blank line has nothing to hover" {
const text = "#!srfv1\n\nk::v\n";
try testing.expectEqual(@as(?Token, null), tokenAt(text, 8));
}
test "an offset past the end has nothing to hover" {
const text = "#!srfv1\nk::v\n";
try testing.expectEqual(@as(?Token, null), tokenAt(text, 9999));
}
test "an empty document has nothing to hover" {
try testing.expectEqual(@as(?Token, null), tokenAt("", 0));
}
test "multi-byte values are located by byte offset" {
const text = "#!srfv1\n#!long\ncost:num:¥15,000\n";
const token = tokenAt(text, midpointOf(text, "15,000")).?;
try testing.expectEqualStrings("¥15,000", token.what.value.value);
}
test "declaredLength only accepts an all-digit hint" {
const text = "#!srfv1\nk:num:1\n";
const token = tokenAt(text, midpointOf(text, "num")).?;
try testing.expectEqual(@as(?usize, null), token.what.hint.declaredLength());
}
test "records after a blank line are still scanned" {
const text = "#!srfv1\n#!long\nname::alice\n\nname::bob\n";
const token = tokenAt(text, midpointOf(text, "bob")).?;
try testing.expectEqualStrings("bob", token.what.value.value);
}
test "the eof directive is a directive" {
const text = "#!srfv1\nk::v\n#!eof\n";
const token = tokenAt(text, midpointOf(text, "#!eof")).?;
try testing.expectEqualStrings("eof", token.what.directive.name);
}
test "a length prefix longer than the document swallows the rest of it" {
// Deliberate: the format says the value is N bytes, so if the file ends
// early the trailing text really is part of that value. Diagnostics flag the
// mismatch; the locator just reports what the declaration implies rather
// than inventing a boundary.
const text = "#!srfv1\n#!long\nbad:99:short\n\n";
const on_blank = tokenAt(text, text.len - 1).?;
try testing.expect(on_blank.what == .value);
try testing.expectEqualStrings("bad", on_blank.what.value.key);
try testing.expectEqual(@as(?usize, 99), on_blank.what.value.declaredLength());
// Everything from after the second colon to the end of the document.
try testing.expectEqualStrings("short\n\n", on_blank.what.value.value);
}
test "a blank line outside any value has nothing to hover" {
const text = "#!srfv1\n#!long\nname::alice\n\nname::bob\n";
const blank_at = std.mem.indexOf(u8, text, "alice\n\n").? + 6;
try testing.expectEqual(@as(?Token, null), tokenAt(text, blank_at));
}
/// The length problem for the field containing `offset`, if any.
fn problemAt(text: []const u8, needle: []const u8) ?LengthProblem {
const offset = midpointOf(text, needle);
var it = items(text);
while (it.next()) |item| switch (item) {
.field => |located| {
if (located.key_span.contains(offset) or located.value_span.contains(offset) or
(located.hint_span != null and located.hint_span.?.contains(offset)))
{
return located.field.length_problem;
}
},
else => {},
};
return null;
}
test "a correct length prefix has no problem" {
try testing.expectEqual(
@as(?LengthProblem, null),
problemAt("#!srfv1\n#!long\nk:5:hello\n", "hello"),
);
}
test "a length shorter than the line is an overrun, which srf silently ignores" {
// srf keeps the first 3 bytes and drops "lo" without a word, so this is the
// one that most needs reporting.
const problem = problemAt("#!srfv1\n#!long\nk:3:hello\n", "k:3:").?;
try testing.expectEqual(@as(usize, 3), problem.overrun.declared);
// "lo" is the surplus srf throws away.
try testing.expectEqual(@as(usize, 2), problem.overrun.surplus);
}
test "a length past the end of the document is truncation" {
const problem = problemAt("#!srfv1\n#!long\nk:99:hello\n", "hello").?;
try testing.expectEqual(@as(usize, 99), problem.truncated.declared);
// "hello\n" is all that remains.
try testing.expectEqual(@as(usize, 6), problem.truncated.available);
}
test "a length ending exactly at end of file is fine" {
// No trailing newline, so EOF is the terminator.
try testing.expectEqual(
@as(?LengthProblem, null),
problemAt("#!srfv1\n#!long\nk:5:hello", "hello"),
);
}
test "in compact format a comma terminates a length-prefixed value" {
try testing.expectEqual(
@as(?LengthProblem, null),
problemAt("#!srfv1\nk:5:a,b,c,next::x\n", "a,b,c"),
);
}
test "in compact format a length landing mid-token is an overrun" {
// After 2 bytes comes 'b', neither a comma nor a newline.
const problem = problemAt("#!srfv1\nk:2:abc\n", "k:2:").?;
try testing.expectEqual(@as(usize, 2), problem.overrun.declared);
}
test "in long format a trailing comma after the declared bytes is an overrun" {
// Long format has no comma delimiter, so the comma is stray text.
const problem = problemAt("#!srfv1\n#!long\nk:5:hello,\n", "hello").?;
try testing.expectEqual(@as(usize, 5), problem.overrun.declared);
// Just the stray comma.
try testing.expectEqual(@as(usize, 1), problem.overrun.surplus);
}
test "trailing whitespace after the declared bytes is an overrun" {
const problem = problemAt("#!srfv1\n#!long\nk:5:hello \n", "hello").?;
try testing.expectEqual(@as(usize, 5), problem.overrun.declared);
}
test "a multi-line length-prefixed value has no problem" {
try testing.expectEqual(
@as(?LengthProblem, null),
problemAt("#!srfv1\n#!long\nbio:7:foo\nbar\nname::alice\n", "foo"),
);
}
test "a keyword hint never reports a length problem" {
try testing.expectEqual(
@as(?LengthProblem, null),
problemAt("#!srfv1\n#!long\nk:string:hello\n", "hello"),
);
}
test "items yields every construct in document order" {
const text = "#!srfv1\n#!long\n# comment\nname::alice\n\nage:num:30\n#!eof\n";
var it = items(text);
var kinds: std.ArrayList([]const u8) = .empty;
defer kinds.deinit(testing.allocator);
while (it.next()) |item| {
try kinds.append(testing.allocator, switch (item) {
.magic => "magic",
.directive => "directive",
.field => "field",
});
}
try testing.expectEqualDeep(
@as([]const []const u8, &.{ "magic", "directive", "field", "field", "directive" }),
kinds.items,
);
}
test "items yields each field of a compact line separately" {
const text = "#!srfv1\nname::alice,age:num:30,ok:bool:true\n";
var it = items(text);
var keys: std.ArrayList([]const u8) = .empty;
defer keys.deinit(testing.allocator);
while (it.next()) |item| switch (item) {
.field => |f| try keys.append(testing.allocator, f.field.key),
else => {},
};
try testing.expectEqualDeep(
@as([]const []const u8, &.{ "name", "age", "ok" }),
keys.items,
);
}
test "items terminates on a line with no colon" {
const text = "#!srfv1\ngarbage\nk::v\n";
var it = items(text);
var fields: usize = 0;
while (it.next()) |item| switch (item) {
.field => fields += 1,
else => {},
};
try testing.expectEqual(@as(usize, 1), fields);
}
test "the surplus of a multi-line overrun is measured on its own line" {
// Declares 5 bytes ("foo\nb"), so the surplus is "ar" on the second line.
// Measuring from the first line's end would produce an inverted range.
const problem = problemAt("#!srfv1\n#!long\nbio:5:foo\nbar\n", "bio").?;
try testing.expectEqual(@as(usize, 5), problem.overrun.declared);
try testing.expectEqual(@as(usize, 2), problem.overrun.surplus);
const text = "#!srfv1\n#!long\nbio:5:foo\nbar\n";
try testing.expectEqualStrings(
"ar",
text[problem.overrun.surplus_start..][0..problem.overrun.surplus],
);
}

682
src/hover.zig Normal file
View file

@ -0,0 +1,682 @@
//! Renders hover text for the SRF construct under the cursor.
//!
//! `document.zig` finds *what* is under the cursor; this module says what it
//! means. Where a value's meaning depends on parsing (is `¥15,000` a valid
//! `num`? how many bytes does that base64 decode to?), we ask srf rather than
//! reimplementing its rules, by re-parsing the single located field in isolation.
//! That keeps hover and diagnostics from ever disagreeing about the same text.
const std = @import("std");
const srf = @import("srf");
const document = @import("document.zig");
const positions = @import("positions.zig");
pub const MarkupContent = struct {
kind: []const u8 = "markdown",
/// Owned by the `Hover`.
value: []const u8,
};
pub const Hover = struct {
contents: MarkupContent,
range: positions.Range,
pub fn deinit(self: Hover, allocator: std.mem.Allocator) void {
allocator.free(self.contents.value);
}
};
/// Builds hover content for `position`, or null when there is nothing useful
/// under the cursor (a comment, a blank line, punctuation, past the end).
///
/// Caller owns the result and should release it with `Hover.deinit`.
pub fn hoverAt(
allocator: std.mem.Allocator,
text: []const u8,
position: positions.Position,
encoding: positions.Encoding,
) error{OutOfMemory}!?Hover {
const offset = positions.byteFromPosition(text, position, encoding);
const token = document.tokenAt(text, offset) orelse return null;
var out: std.Io.Writer.Allocating = .init(allocator);
defer out.deinit();
// Writing to an Allocating writer can only fail by running out of memory.
render(allocator, &out.writer, token) catch |err| switch (err) {
error.WriteFailed => return error.OutOfMemory,
};
const owned = try allocator.dupe(u8, out.written());
return .{
.contents = .{ .value = owned },
.range = positions.rangeFromSpan(text, token.span.start, token.span.end, encoding),
};
}
fn render(
allocator: std.mem.Allocator,
w: *std.Io.Writer,
token: document.Token,
) std.Io.Writer.Error!void {
switch (token.what) {
.magic => try w.writeAll(
\\**SRF v1** (Simple Record Format)
\\
\\Mandatory first line. Declares the format and version, and must be
\\the first line of the file.
),
.directive => |d| try renderDirective(w, d),
.key => |f| try renderField(allocator, w, f),
.hint => |f| try renderHint(allocator, w, f),
.value => |f| try renderValue(allocator, w, f),
}
}
//
// Directives
//
fn renderDirective(w: *std.Io.Writer, d: document.Directive) std.Io.Writer.Error!void {
if (std.mem.eql(u8, d.name, "long")) {
return w.writeAll(
\\**`#!long`** long format
\\
\\Fields are separated by newlines, so a value may contain commas.
\\Required for records that span multiple lines.
);
}
if (std.mem.eql(u8, d.name, "compact")) {
return w.writeAll(
\\**`#!compact`** compact format (the default)
\\
\\Fields are separated by commas, one record per line. A value
\\containing a comma needs a length prefix.
);
}
if (std.mem.eql(u8, d.name, "requireeof")) {
return w.writeAll(
\\**`#!requireeof`**
\\
\\Parsing fails unless `#!eof` appears on the last line. Detects a
\\file that was truncated in transit.
);
}
if (std.mem.eql(u8, d.name, "eof")) {
return w.writeAll(
\\**`#!eof`** end of data
\\
\\Nothing may follow this line. Only enforced when `#!requireeof`
\\is set.
);
}
const label: ?[]const u8 = if (std.mem.eql(u8, d.name, "expires"))
"Data expires at"
else if (std.mem.eql(u8, d.name, "created"))
"Data created at"
else if (std.mem.eql(u8, d.name, "modified"))
"Data last modified at"
else
null;
if (label) |text| {
try w.print("**`#!{s}`**\n\n", .{d.name});
const argument = d.argument orelse {
return w.writeAll("Missing timestamp: this directive needs `=<unix seconds>`.");
};
const seconds = std.fmt.parseInt(i64, argument, 10) catch {
return w.print("`{s}` is not a Unix timestamp in seconds.", .{argument});
};
try w.print("{s} ", .{text});
try writeTimestamp(w, seconds);
return w.print("\n\n(`{d}` seconds since the Unix epoch)", .{seconds});
}
try w.print("**`#!{s}`** unrecognised directive\n\n", .{d.name});
try w.writeAll("Parsers ignore directives they do not know, so this is not an error.");
}
/// Writes a Unix timestamp as `YYYY-MM-DD HH:MM:SS UTC`. Nobody can read an
/// epoch second, which is the whole reason this function exists.
fn writeTimestamp(w: *std.Io.Writer, seconds: i64) std.Io.Writer.Error!void {
if (seconds < 0) {
// std.time.epoch counts from 1970 upwards only. Rather than do signed
// civil-date maths for a case that should not occur in cache metadata,
// say so plainly.
return w.print("a time before 1970 (`{d}`)", .{seconds});
}
const epoch: std.time.epoch.EpochSeconds = .{ .secs = @intCast(seconds) };
const day = epoch.getEpochDay().calculateYearDay();
const month_day = day.calculateMonthDay();
const time = epoch.getDaySeconds();
try w.print("{d:0>4}-{d:0>2}-{d:0>2} {d:0>2}:{d:0>2}:{d:0>2} UTC", .{
day.year,
month_day.month.numeric(),
month_day.day_index + 1,
time.getHoursIntoDay(),
time.getMinutesIntoHour(),
time.getSecondsIntoMinute(),
});
}
//
// Fields
//
fn renderField(
allocator: std.mem.Allocator,
w: *std.Io.Writer,
f: document.Field,
) std.Io.Writer.Error!void {
try w.print("**`{s}`**", .{f.key});
if (f.hint) |hint| try w.print(" · `{s}`", .{hint}) else try w.writeAll(" · untyped");
try w.writeAll("\n\n");
try describeValue(allocator, w, f);
}
fn renderHint(
allocator: std.mem.Allocator,
w: *std.Io.Writer,
f: document.Field,
) std.Io.Writer.Error!void {
const hint = f.hint orelse return;
if (f.declaredLength()) |declared| {
try w.print("**length prefix: {d} bytes**\n\n", .{declared});
try w.writeAll(
\\The value is exactly this many bytes, newlines included, so it can
\\hold delimiters and line breaks without escaping.
\\
\\
);
return describeValue(allocator, w, f);
}
const explanation: ?[]const u8 = if (std.mem.eql(u8, hint, "string"))
"Text, ending at the field delimiter. No escaping: a value that needs to contain the delimiter or a newline uses a length prefix instead."
else if (std.mem.eql(u8, hint, "num"))
"A number, parsed as a 64-bit float."
else if (std.mem.eql(u8, hint, "bool"))
"`true` or `false`."
else if (std.mem.eql(u8, hint, "null"))
"No value."
else if (std.mem.eql(u8, hint, "binary"))
"Base64-encoded bytes."
else
null;
if (explanation) |text| {
try w.print("**`{s}`**\n\n{s}\n\n", .{ hint, text });
return describeValue(allocator, w, f);
}
try w.print("**`{s}`** unrecognised type hint\n\n", .{hint});
try w.writeAll("SRF knows `string`, `num`, `bool`, `null`, `binary`, and a byte count for a length-prefixed value.");
}
fn renderValue(
allocator: std.mem.Allocator,
w: *std.Io.Writer,
f: document.Field,
) std.Io.Writer.Error!void {
try w.print("**`{s}`**", .{f.key});
if (f.hint) |hint| try w.print(" · `{s}`", .{hint});
try w.writeAll("\n\n");
try describeValue(allocator, w, f);
}
/// The part that has to agree with the reference parser, so it asks the reference
/// parser.
fn describeValue(
allocator: std.mem.Allocator,
w: *std.Io.Writer,
f: document.Field,
) std.Io.Writer.Error!void {
if (f.length_problem) |problem| try writeLengthProblem(w, problem, f);
if (f.value.len == 0) {
return w.writeAll("*No value.*");
}
try describeParsed(allocator, w, f);
try w.print("```\n{s}\n```", .{f.value});
}
/// A wrong length prefix is the easiest way to corrupt an SRF file by hand, and
/// the byte count is invisible in the text, so say exactly what is wrong.
fn writeLengthProblem(
w: *std.Io.Writer,
problem: document.LengthProblem,
f: document.Field,
) std.Io.Writer.Error!void {
switch (problem) {
.truncated => |t| try w.print(
"**Length mismatch:** declares {d} byte{s} but only {d} remain in the file.\n\n",
.{ t.declared, plural(t.declared), t.available },
),
.overrun => |o| {
try w.print(
"**Length mismatch:** declares {d} byte{s}, but the value does not end there",
.{ o.declared, plural(o.declared) },
);
if (f.long_mode) {
try w.print(
": {d} more byte{s} follow.\n\nsrf keeps the first {d} and discards the rest without reporting it.\n\n",
.{ o.surplus, plural(o.surplus), o.declared },
);
} else {
// srf reports this one itself, fatally. Hover is still where the
// detail belongs, so explain the rule rather than just repeating
// that something is wrong.
try w.writeAll(
". A length-prefixed value must be followed by a comma or end of line, so srf rejects the document.\n\n",
);
}
},
}
}
/// Asks srf what the value means, and describes it while the parse is still
/// alive: decoded `binary` bytes live in the parser's arena.
fn describeParsed(
allocator: std.mem.Allocator,
w: *std.Io.Writer,
f: document.Field,
) std.Io.Writer.Error!void {
// No hint, a plain string, or a length prefix all mean "text": nothing to
// parse and nothing that can fail.
const hint = f.hint orelse return writeByteCount(w, f.value);
if (f.declaredLength() != null) return writeByteCount(w, f.value);
if (std.mem.eql(u8, hint, "string")) return writeByteCount(w, f.value);
const synthetic = buildSyntheticDocument(allocator, f) catch return writeUnparseable(w, hint);
defer allocator.free(synthetic);
var reader = std.Io.Reader.fixed(synthetic);
var it = srf.iterator(&reader, allocator, .{}) catch return writeUnparseable(w, hint);
defer it.deinit();
const fields = (it.next() catch return writeUnparseable(w, hint)) orelse
return writeUnparseable(w, hint);
const field = (fields.next() catch return writeUnparseable(w, hint)) orelse
return writeUnparseable(w, hint);
const value = field.value orelse return writeUnparseable(w, hint);
switch (value) {
.number => |n| try w.print("Parsed as a number: `{d}`\n\n", .{n}),
.boolean => |b| try w.print("Parsed as a boolean: `{}`\n\n", .{b}),
.bytes => |decoded| try writeDecodedBytes(w, decoded),
.string => try writeByteCount(w, f.value),
}
}
fn writeByteCount(w: *std.Io.Writer, value: []const u8) std.Io.Writer.Error!void {
try w.print("{d} byte{s}\n\n", .{ value.len, plural(value.len) });
}
fn writeUnparseable(w: *std.Io.Writer, hint: []const u8) std.Io.Writer.Error!void {
try w.print("**Does not parse as `{s}`.** srf would report an error here.\n\n", .{hint});
}
/// Longest decoded preview shown inline. Past this the point is made and the
/// hover starts crowding the buffer.
const preview_limit = 48;
/// Describes decoded base64. Shows the content when it is text, because that is
/// the whole reason you would base64 a value in a human-readable format.
fn writeDecodedBytes(w: *std.Io.Writer, decoded: []const u8) std.Io.Writer.Error!void {
try w.print("Base64, decoding to {d} byte{s}", .{ decoded.len, plural(decoded.len) });
if (decoded.len == 0) return w.writeAll("\n\n");
if (!looksLikeText(decoded)) return w.writeAll(": binary content\n\n");
try w.writeAll(": \"");
const shown = decoded[0..@min(decoded.len, preview_limit)];
try writeEscaped(w, shown);
if (shown.len < decoded.len) try w.writeAll("...");
try w.writeAll("\"\n\n");
}
/// Writes text on a single line, so a decoded value containing newlines cannot
/// break the surrounding markdown.
fn writeEscaped(w: *std.Io.Writer, bytes: []const u8) std.Io.Writer.Error!void {
for (bytes) |c| switch (c) {
'\n' => try w.writeAll("\\n"),
'\r' => try w.writeAll("\\r"),
'\t' => try w.writeAll("\\t"),
'"' => try w.writeAll("\\\""),
'\\' => try w.writeAll("\\\\"),
else => try w.writeByte(c),
};
}
/// Whether decoded bytes are worth showing as text: valid UTF-8 with no control
/// characters other than the usual whitespace.
fn looksLikeText(bytes: []const u8) bool {
if (!std.unicode.utf8ValidateSlice(bytes)) return false;
for (bytes) |c| {
if (c == '\t' or c == '\n' or c == '\r') continue;
if (c < 0x20 or c == 0x7f) return false;
}
return true;
}
fn buildSyntheticDocument(
allocator: std.mem.Allocator,
f: document.Field,
) error{OutOfMemory}![]u8 {
var buf: std.Io.Writer.Allocating = .init(allocator);
errdefer buf.deinit();
const w = &buf.writer;
// Writes to an Allocating writer only fail on OOM.
w.writeAll("#!srfv1\n") catch return error.OutOfMemory;
if (f.long_mode) w.writeAll("#!long\n") catch return error.OutOfMemory;
if (f.hint) |hint| {
w.print("{s}:{s}:{s}\n", .{ f.key, hint, f.value }) catch return error.OutOfMemory;
} else {
w.print("{s}::{s}\n", .{ f.key, f.value }) catch return error.OutOfMemory;
}
return buf.toOwnedSlice();
}
fn plural(n: usize) []const u8 {
return if (n == 1) "" else "s";
}
const testing = std.testing;
/// Hovers at the midpoint of `needle` and returns the markdown.
fn hoverOn(text: []const u8, needle: []const u8) ![]const u8 {
const at = std.mem.indexOf(u8, text, needle).?;
const position = positions.positionFromByte(text, at + needle.len / 2, .@"utf-8");
const hover = (try hoverAt(testing.allocator, text, position, .@"utf-8")).?;
return hover.contents.value;
}
fn expectHoverContains(text: []const u8, needle: []const u8, expected: []const u8) !void {
const markdown = try hoverOn(text, needle);
defer testing.allocator.free(markdown);
if (std.mem.indexOf(u8, markdown, expected) == null) {
std.debug.print("\nhovering \"{s}\"\nexpected to contain: {s}\ngot:\n{s}\n", .{ needle, expected, markdown });
return error.MissingFromHover;
}
}
test "hovering the magic header explains it" {
try expectHoverContains("#!srfv1\nk::v\n", "#!srfv1", "Simple Record Format");
}
test "hovering #!long explains the delimiter change" {
try expectHoverContains("#!srfv1\n#!long\nk::v\n", "#!long", "separated by newlines");
}
test "hovering #!compact says it is the default" {
try expectHoverContains("#!srfv1\n#!compact\nk::v\n", "#!compact", "the default");
}
test "hovering #!requireeof explains truncation detection" {
try expectHoverContains("#!srfv1\n#!requireeof\nk::v\n", "#!requireeof", "truncated");
}
test "hovering #!eof explains it" {
try expectHoverContains("#!srfv1\nk::v\n#!eof\n", "#!eof", "end of data");
}
test "hovering #!expires renders the timestamp as a date" {
const text = "#!srfv1\n#!expires=1772589213\nk::v\n";
try expectHoverContains(text, "expires", "2026-03-04");
try expectHoverContains(text, "expires", "UTC");
// The raw value stays visible; the date is a convenience, not a replacement.
try expectHoverContains(text, "expires", "1772589213");
}
test "hovering #!created and #!modified render dates too" {
try expectHoverContains("#!srfv1\n#!created=0\nk::v\n", "created", "1970-01-01 00:00:00 UTC");
try expectHoverContains("#!srfv1\n#!modified=86400\nk::v\n", "modified", "1970-01-02");
}
test "a non-numeric timestamp is called out, not silently mangled" {
try expectHoverContains("#!srfv1\n#!expires=tomorrow\nk::v\n", "expires", "not a Unix timestamp");
}
test "a timestamp before 1970 is described rather than wrapped" {
try expectHoverContains("#!srfv1\n#!expires=-100\nk::v\n", "expires", "before 1970");
}
test "an unknown directive says parsers ignore it" {
try expectHoverContains("#!srfv1\n#!wat=1\nk::v\n", "#!wat", "ignore directives they do not know");
}
test "hovering an untyped key shows the key and its value" {
const text = "#!srfv1\nname::alice\n";
try expectHoverContains(text, "name", "`name`");
try expectHoverContains(text, "name", "untyped");
try expectHoverContains(text, "name", "alice");
}
test "hovering a num key shows the parsed number" {
try expectHoverContains("#!srfv1\nage:num:30\n", "age", "Parsed as a number: `30`");
}
test "hovering a num hint explains the type" {
try expectHoverContains("#!srfv1\nage:num:30\n", "num", "64-bit float");
}
test "hovering a bool value shows the parsed boolean" {
try expectHoverContains("#!srfv1\nok:bool:true\n", "true", "Parsed as a boolean: `true`");
}
test "hovering a binary value reports the decoded size" {
// "aGVsbG8=" is "hello": 5 bytes.
try expectHoverContains("#!srfv1\nblob:binary:aGVsbG8=\n", "aGVsbG8", "decoding to 5 bytes");
}
test "hovering a string value reports its byte length" {
try expectHoverContains("#!srfv1\ns:string:hello\n", "hello", "5 bytes");
}
test "a value that does not parse as its type says so" {
try expectHoverContains("#!srfv1\nn:num:not-a-number\n", "not-a-number", "Does not parse as `num`");
}
test "a lone byte is singular" {
try expectHoverContains("#!srfv1\ns:string:x\n", "s:string:x", "1 byte");
}
test "hovering a length prefix explains the byte count" {
const text = "#!srfv1\n#!long\nbio:7:foo\nbar\n";
try expectHoverContains(text, ":7:", "length prefix: 7 bytes");
try expectHoverContains(text, ":7:", "newlines included");
}
test "a length-prefixed value shows its multi-line content" {
try expectHoverContains("#!srfv1\n#!long\nbio:7:foo\nbar\n", "foo", "foo\nbar");
}
test "a wrong length prefix is flagged" {
// Declares 99 bytes; the line holds far fewer.
try expectHoverContains("#!srfv1\n#!long\nbio:99:short\n", "short", "Length mismatch");
}
test "an empty value says so" {
try expectHoverContains("#!srfv1\n#!long\nmissing:null:\n", "missing", "No value");
}
test "currency in a num is judged by srf, not by us" {
// srf's default is strict number parsing, so this does not parse. Whatever
// srf decides, hover agrees with it: that is the point of the re-parse.
const text = "#!srfv1\n#!long\ncost:num:¥15,000\n";
const markdown = try hoverOn(text, "15,000");
defer testing.allocator.free(markdown);
const parsed = std.mem.indexOf(u8, markdown, "Parsed as a number") != null;
const rejected = std.mem.indexOf(u8, markdown, "Does not parse") != null;
try testing.expect(parsed or rejected);
}
test "long mode is preserved when re-parsing, so commas stay in the value" {
const text = "#!srfv1\n#!long\nname::alice, bob\n";
try expectHoverContains(text, "bob", "alice, bob");
}
test "hovering a comment gives nothing" {
const text = "#!srfv1\n# a comment\nk::v\n";
const at = std.mem.indexOf(u8, text, "comment").?;
const position = positions.positionFromByte(text, at, .@"utf-8");
try testing.expectEqual(
@as(?Hover, null),
try hoverAt(testing.allocator, text, position, .@"utf-8"),
);
}
test "hovering past the end gives nothing" {
try testing.expectEqual(
@as(?Hover, null),
try hoverAt(testing.allocator, "#!srfv1\n", .{ .line = 50, .character = 0 }, .@"utf-8"),
);
}
test "hover reports the range of the thing it described" {
const text = "#!srfv1\nname::alice\n";
const hover = (try hoverAt(testing.allocator, text, .{ .line = 1, .character = 2 }, .@"utf-8")).?;
defer hover.deinit(testing.allocator);
try testing.expectEqual(@as(u32, 1), hover.range.start.line);
try testing.expectEqual(@as(u32, 0), hover.range.start.character);
try testing.expectEqual(@as(u32, 4), hover.range.end.character);
}
test "hover ranges use the negotiated encoding" {
const text = "#!srfv1\n#!long\ncost:num:¥15,000\n";
// Hover the value, whose line contains a two-byte character before it.
const at = std.mem.indexOf(u8, text, "15,000").?;
const utf8 = (try hoverAt(
testing.allocator,
text,
positions.positionFromByte(text, at, .@"utf-8"),
.@"utf-8",
)).?;
defer utf8.deinit(testing.allocator);
const utf16 = (try hoverAt(
testing.allocator,
text,
positions.positionFromByte(text, at, .@"utf-16"),
.@"utf-16",
)).?;
defer utf16.deinit(testing.allocator);
// The span starts at the yen sign itself, so both encodings agree there:
// nothing multi-byte precedes it on the line.
try testing.expectEqual(utf8.range.start.character, utf16.range.start.character);
// They diverge at the end, which sits past it: two bytes but one UTF-16 unit.
try testing.expectEqual(utf8.range.end.character - 1, utf16.range.end.character);
}
test "the markdown declares itself as markdown" {
const text = "#!srfv1\nname::alice\n";
const hover = (try hoverAt(testing.allocator, text, .{ .line = 1, .character = 2 }, .@"utf-8")).?;
defer hover.deinit(testing.allocator);
try testing.expectEqualStrings("markdown", hover.contents.kind);
}
test "timestamps are correct across leap years and the 2038 boundary" {
// Verified against Python's datetime for each of these.
const cases = [_]struct { secs: []const u8, expected: []const u8 }{
.{ .secs = "0", .expected = "1970-01-01 00:00:00 UTC" },
.{ .secs = "1", .expected = "1970-01-01 00:00:01 UTC" },
.{ .secs = "951782400", .expected = "2000-02-29 00:00:00 UTC" },
.{ .secs = "1078012800", .expected = "2004-02-29 00:00:00 UTC" },
.{ .secs = "1709164800", .expected = "2024-02-29 00:00:00 UTC" },
.{ .secs = "1772589213", .expected = "2026-03-04 01:53:33 UTC" },
.{ .secs = "2147483647", .expected = "2038-01-19 03:14:07 UTC" },
};
for (cases) |case| {
var out: std.Io.Writer.Allocating = .init(testing.allocator);
defer out.deinit();
const secs = try std.fmt.parseInt(i64, case.secs, 10);
try writeTimestamp(&out.writer, secs);
try testing.expectEqualStrings(case.expected, out.written());
}
}
test "base64 text content is shown, not just its size" {
// "aGVsbG8=" is "hello".
try expectHoverContains("#!srfv1\nblob:binary:aGVsbG8=\n", "aGVsbG8", "decoding to 5 bytes: \"hello\"");
}
test "base64 binary content is described rather than dumped" {
// "AAAAAA==" is two base64 groups: four 0x00 bytes, which are not text.
try expectHoverContains("#!srfv1\nblob:binary:AAAAAA==\n", "AAAAAA", "decoding to 4 bytes: binary content");
}
test "base64 text with newlines stays on one line" {
// "aGkKdGhlcmU=" is "hi\nthere".
try expectHoverContains("#!srfv1\nblob:binary:aGkKdGhlcmU=\n", "aGkK", "\"hi\\nthere\"");
}
test "a long base64 text preview is truncated" {
// 60 'a' characters, base64 encoded.
var raw: [60]u8 = @splat('a');
var encoded: [128]u8 = undefined;
const b64 = std.base64.standard.Encoder.encode(&encoded, &raw);
const text = try std.fmt.allocPrint(testing.allocator, "#!srfv1\nblob:binary:{s}\n", .{b64});
defer testing.allocator.free(text);
const markdown = try hoverOn(text, b64);
defer testing.allocator.free(markdown);
try testing.expect(std.mem.indexOf(u8, markdown, "decoding to 60 bytes") != null);
try testing.expect(std.mem.indexOf(u8, markdown, "...") != null);
}
test "empty base64 decodes to nothing without a preview" {
const markdown = try hoverOn("#!srfv1\nblob:binary:\n", "binary");
defer testing.allocator.free(markdown);
try testing.expect(std.mem.indexOf(u8, markdown, "No value") != null);
}
test "looksLikeText accepts text and rejects control bytes" {
try testing.expect(looksLikeText("hello"));
try testing.expect(looksLikeText("hi\nthere\t!"));
try testing.expect(looksLikeText("¥15,000"));
try testing.expect(!looksLikeText("\x00\x01"));
try testing.expect(!looksLikeText("\xff\xfe"));
try testing.expect(!looksLikeText("bad\x07bell"));
}
test "an overrun length in long format explains srf's silent truncation" {
const text = "#!srfv1\n#!long\nk:3:hello\n";
try expectHoverContains(text, "k:3:", "declares 3 bytes, but the value does not end there");
try expectHoverContains(text, "k:3:", "2 more bytes follow");
try expectHoverContains(text, "k:3:", "discards the rest without reporting it");
}
test "a truncated length reports what is actually left" {
const text = "#!srfv1\n#!long\nk:99:hello\n";
try expectHoverContains(text, "hello", "declares 99 bytes but only 6 remain");
}
test "an overrun length in compact format explains the terminator rule" {
const text = "#!srfv1\nk:2:abc\n";
try expectHoverContains(text, "k:2:", "followed by a comma or end of line");
// Hover explains it even though the diagnostic for it comes from srf.
try expectHoverContains(text, "k:2:", "srf rejects the document");
}
test "a correct length prefix reports no mismatch" {
const markdown = try hoverOn("#!srfv1\n#!long\nk:5:hello\n", "hello");
defer testing.allocator.free(markdown);
try testing.expect(std.mem.indexOf(u8, markdown, "Length mismatch") == null);
}
test "a correct length prefix with commas in compact format reports no mismatch" {
const markdown = try hoverOn("#!srfv1\nk:5:a,b,c,next::x\n", "a,b,c");
defer testing.allocator.free(markdown);
try testing.expect(std.mem.indexOf(u8, markdown, "Length mismatch") == null);
try testing.expect(std.mem.indexOf(u8, markdown, "a,b,c") != null);
}

View file

@ -8,13 +8,31 @@ const std = @import("std");
const rpc = @import("rpc.zig");
const Server = @import("Server.zig");
/// `std.log.defaultLog` writes to stderr, which is exactly what we need: stdout
/// is the protocol stream and a stray byte on it desynchronises the client. So
/// there is no custom `logFn` here on purpose.
/// `std.log.defaultLog` writes to stderr, which is what we need: stdout is the
/// protocol stream and a stray byte on it desynchronises the client.
pub const std_options: std.Options = .{
.log_level = .info,
.logFn = logFn,
};
/// Demotes the srf library's own logging to debug.
///
/// srf logs at error level for conditions that are not server errors: a failed
/// custom `srfParse` coercion at `srf.zig:556`, for instance. Real problems in the
/// document reach the user as diagnostics, so passing these through would make
/// `:LspLog` look like the server broke. They are kept, but only surface when
/// debug logging is on.
fn logFn(
comptime level: std.log.Level,
comptime scope: @EnumLiteral(),
comptime format: []const u8,
args: anytype,
) void {
const effective = if (scope == .srf) std.log.Level.debug else level;
if (@intFromEnum(effective) > @intFromEnum(std.options.log_level)) return;
std.log.defaultLog(effective, scope, format, args);
}
const log = std.log.scoped(.srf_lsp);
/// Returns the process exit code: 0 for an orderly shutdown, 1 otherwise, which
@ -66,4 +84,7 @@ test {
_ = @import("rpc.zig");
_ = @import("Server.zig");
_ = @import("analysis.zig");
_ = @import("document.zig");
_ = @import("positions.zig");
_ = @import("hover.zig");
}

277
src/positions.zig Normal file
View file

@ -0,0 +1,277 @@
//! Converting between byte offsets in a document and LSP positions.
//!
//! LSP counts a `character` in UTF-16 code units by default, while everything
//! else here (srf's columns, `document.zig`'s spans, plain slicing) counts bytes.
//! The client picks the encoding during `initialize`, so both directions are
//! needed and neither can assume one byte per character.
const std = @import("std");
/// How LSP positions are counted. The client picks this during `initialize`.
pub const Encoding = enum {
/// Byte offsets. What srf and our own scanner already use, so no conversion.
@"utf-8",
/// UTF-16 code units. The protocol default, so it is the fallback.
@"utf-16",
/// Codepoints.
@"utf-32",
pub fn fromWireName(name: []const u8) ?Encoding {
return std.meta.stringToEnum(Encoding, name);
}
pub fn wireName(self: Encoding) []const u8 {
return @tagName(self);
}
};
pub const Position = struct {
line: u32,
character: u32,
};
pub const Range = struct {
start: Position,
end: Position,
};
/// Returns the 0-based `index`th line of `text`, excluding its newline and any
/// carriage return. Empty if the line does not exist.
pub fn lineAt(text: []const u8, index: u32) []const u8 {
var remaining = text;
var i: u32 = 0;
while (true) {
const nl = std.mem.indexOfScalar(u8, remaining, '\n');
if (i == index) {
const line = if (nl) |n| remaining[0..n] else remaining;
return std.mem.trimEnd(u8, line, "\r");
}
if (nl) |n| {
remaining = remaining[n + 1 ..];
i += 1;
} else {
return "";
}
}
}
/// Byte offset at which the 0-based `index`th line starts. Clamped to the end of
/// the text for a line past the end.
pub fn lineStart(text: []const u8, index: u32) usize {
var offset: usize = 0;
var i: u32 = 0;
while (i < index) : (i += 1) {
const nl = std.mem.indexOfScalarPos(u8, text, offset, '\n') orelse return text.len;
offset = nl + 1;
}
return @min(offset, text.len);
}
/// Converts a byte offset within one line to a character offset in `encoding`.
///
/// Invalid UTF-8 is counted a byte at a time rather than rejected: a document
/// being edited is routinely invalid for a keystroke or two, and a usable
/// position beats an error.
pub fn characterFromByte(line: []const u8, byte_offset: usize, encoding: Encoding) u32 {
const limit = @min(byte_offset, line.len);
if (encoding == .@"utf-8") return @intCast(limit);
var count: u32 = 0;
var i: usize = 0;
while (i < limit) {
const decoded = decodeAt(line, i) orelse {
i += 1;
count += 1;
continue;
};
count += switch (encoding) {
// Anything outside the BMP needs two UTF-16 code units.
.@"utf-16" => if (decoded.codepoint > 0xFFFF) @as(u32, 2) else 1,
.@"utf-32" => 1,
.@"utf-8" => unreachable,
};
i += decoded.len;
}
return count;
}
/// Converts a character offset within one line back to a byte offset. Clamped to
/// the length of the line.
pub fn byteFromCharacter(line: []const u8, character: u32, encoding: Encoding) usize {
if (encoding == .@"utf-8") return @min(character, line.len);
var seen: u32 = 0;
var i: usize = 0;
while (i < line.len) {
if (seen >= character) return i;
const decoded = decodeAt(line, i) orelse {
i += 1;
seen += 1;
continue;
};
seen += switch (encoding) {
.@"utf-16" => if (decoded.codepoint > 0xFFFF) @as(u32, 2) else 1,
.@"utf-32" => 1,
.@"utf-8" => unreachable,
};
i += decoded.len;
}
return line.len;
}
/// Whole-document byte offset for an LSP position.
pub fn byteFromPosition(text: []const u8, position: Position, encoding: Encoding) usize {
const start = lineStart(text, position.line);
const line = lineAt(text, position.line);
return @min(start + byteFromCharacter(line, position.character, encoding), text.len);
}
/// LSP position for a whole-document byte offset.
pub fn positionFromByte(text: []const u8, offset: usize, encoding: Encoding) Position {
const limit = @min(offset, text.len);
var line: u32 = 0;
var i: usize = 0;
while (std.mem.indexOfScalarPos(u8, text, i, '\n')) |nl| {
if (nl >= limit) break;
line += 1;
i = nl + 1;
}
return .{
.line = line,
.character = characterFromByte(lineAt(text, line), limit - i, encoding),
};
}
/// LSP range for a whole-document byte span.
pub fn rangeFromSpan(text: []const u8, start: usize, end: usize, encoding: Encoding) Range {
return .{
.start = positionFromByte(text, start, encoding),
.end = positionFromByte(text, end, encoding),
};
}
const Decoded = struct { codepoint: u21, len: usize };
/// Decodes one codepoint, or null if the bytes at `i` are not valid UTF-8.
fn decodeAt(bytes: []const u8, i: usize) ?Decoded {
const len = std.unicode.utf8ByteSequenceLength(bytes[i]) catch return null;
if (i + len > bytes.len) return null;
const codepoint = std.unicode.utf8Decode(bytes[i..][0..len]) catch return null;
return .{ .codepoint = codepoint, .len = len };
}
const testing = std.testing;
test "encoding round trips through its wire name" {
try testing.expectEqual(Encoding.@"utf-8", Encoding.fromWireName("utf-8").?);
try testing.expectEqual(Encoding.@"utf-16", Encoding.fromWireName("utf-16").?);
try testing.expectEqual(Encoding.@"utf-32", Encoding.fromWireName("utf-32").?);
try testing.expectEqual(@as(?Encoding, null), Encoding.fromWireName("utf-7"));
try testing.expectEqualStrings("utf-16", Encoding.@"utf-16".wireName());
}
test "lineAt splits on newlines and strips carriage returns" {
const text = "one\r\ntwo\nthree";
try testing.expectEqualStrings("one", lineAt(text, 0));
try testing.expectEqualStrings("two", lineAt(text, 1));
try testing.expectEqualStrings("three", lineAt(text, 2));
try testing.expectEqualStrings("", lineAt(text, 3));
try testing.expectEqualStrings("", lineAt(text, 99));
}
test "lineStart finds where each line begins" {
const text = "one\ntwo\nthree";
try testing.expectEqual(@as(usize, 0), lineStart(text, 0));
try testing.expectEqual(@as(usize, 4), lineStart(text, 1));
try testing.expectEqual(@as(usize, 8), lineStart(text, 2));
try testing.expectEqual(text.len, lineStart(text, 99));
}
test "utf-8 characters are byte offsets" {
const line = "a¥b"; // yen is 2 bytes
try testing.expectEqual(@as(u32, 4), characterFromByte(line, line.len, .@"utf-8"));
}
test "utf-16 characters count code units" {
const line = "a¥b";
try testing.expectEqual(@as(u32, 3), characterFromByte(line, line.len, .@"utf-16"));
}
test "utf-16 counts astral characters as surrogate pairs" {
const line = "a\u{1F600}b"; // emoji: 4 bytes, 2 UTF-16 units, 1 codepoint
try testing.expectEqual(@as(u32, 4), characterFromByte(line, line.len, .@"utf-16"));
try testing.expectEqual(@as(u32, 3), characterFromByte(line, line.len, .@"utf-32"));
try testing.expectEqual(@as(u32, 6), characterFromByte(line, line.len, .@"utf-8"));
}
test "character conversion tolerates invalid utf-8" {
try testing.expectEqual(@as(u32, 3), characterFromByte("a\xffb", 3, .@"utf-16"));
}
test "character conversion tolerates a truncated sequence" {
try testing.expectEqual(@as(u32, 2), characterFromByte("a\xc2", 2, .@"utf-16"));
}
test "character conversion clamps past the end of the line" {
try testing.expectEqual(@as(u32, 3), characterFromByte("abc", 99, .@"utf-16"));
}
test "byteFromCharacter is the inverse of characterFromByte" {
for ([_][]const u8{ "plain ascii", "a¥b€c", "a\u{1F600}b", "" }) |line| {
for ([_]Encoding{ .@"utf-8", .@"utf-16", .@"utf-32" }) |encoding| {
var byte: usize = 0;
while (byte <= line.len) : (byte += 1) {
// Only round trip offsets that sit on a codepoint boundary.
if (byte < line.len and std.unicode.utf8ByteSequenceLength(line[byte]) == error.Utf8InvalidStartByte) continue;
const character = characterFromByte(line, byte, encoding);
try testing.expectEqual(byte, byteFromCharacter(line, character, encoding));
}
}
}
}
test "byteFromCharacter clamps past the end of the line" {
try testing.expectEqual(@as(usize, 3), byteFromCharacter("abc", 99, .@"utf-16"));
}
test "byteFromPosition locates an offset in a multi-line document" {
const text = "#!srfv1\ncost:num:¥15,000\n";
// In utf-16 the yen is one unit, so "15,000" starts at character 10.
const utf16 = byteFromPosition(text, .{ .line = 1, .character = 10 }, .@"utf-16");
try testing.expectEqualStrings("15,000", text[utf16 .. utf16 + 6]);
// In utf-8 the same place is one byte further along.
const utf8 = byteFromPosition(text, .{ .line = 1, .character = 11 }, .@"utf-8");
try testing.expectEqual(utf16, utf8);
}
test "byteFromPosition clamps a position past the end" {
const text = "#!srfv1\n";
try testing.expectEqual(text.len, byteFromPosition(text, .{ .line = 99, .character = 99 }, .@"utf-8"));
}
test "positionFromByte is the inverse of byteFromPosition" {
const text = "#!srfv1\n#!long\ncost:num:¥15,000\nname::alice\n";
var offset: usize = 0;
while (offset <= text.len) : (offset += 1) {
if (offset < text.len and std.unicode.utf8ByteSequenceLength(text[offset]) == error.Utf8InvalidStartByte) continue;
const position = positionFromByte(text, offset, .@"utf-16");
try testing.expectEqual(offset, byteFromPosition(text, position, .@"utf-16"));
}
}
test "positionFromByte reports the right line" {
const text = "a\nb\nc\n";
try testing.expectEqual(@as(u32, 0), positionFromByte(text, 0, .@"utf-8").line);
try testing.expectEqual(@as(u32, 1), positionFromByte(text, 2, .@"utf-8").line);
try testing.expectEqual(@as(u32, 2), positionFromByte(text, 4, .@"utf-8").line);
}
test "rangeFromSpan converts both ends" {
const text = "#!srfv1\nname::alice\n";
const at = std.mem.indexOf(u8, text, "alice").?;
const range = rangeFromSpan(text, at, at + 5, .@"utf-8");
try testing.expectEqual(@as(u32, 1), range.start.line);
try testing.expectEqual(@as(u32, 6), range.start.character);
try testing.expectEqual(@as(u32, 11), range.end.character);
}