srf-lsp/src/document.zig
Emil Lerch f43130c030
All checks were successful
Generic zig build / build (push) Successful in 30s
avoid panics, align to srf
2026-09-03 12:46:06 -07:00

962 lines
37 KiB
Zig

//! 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;
}
/// Which `srf.Value` variant this field's hint produces.
///
/// Coercion cares about the variant, not the hint text: an empty hint,
/// `string`, and a byte count all yield `.string` and coerce identically, so
/// treating them as different types would flag correct documents. srf's own
/// README uses `key:7:...` and `key::...` for the same key.
///
/// Null when the field carries no type evidence:
///
/// * a `null` hint, which is an absent value and mixes legitimately with
/// any other type (`coerce` handles it through `.optional`);
/// * an empty value, which says nothing about the intended type;
/// * an unrecognised hint, which srf already reports as "unrecognized
/// metadata for key", so treating it as a type would double-report.
///
/// The order mirrors `Value.parse`'s dispatch in srf.zig.
pub fn valueKind(self: Field) ?ValueKind {
if (self.value.len == 0) return null;
const hint = self.hint orelse return .string;
if (hint.len == 0 or std.mem.eql(u8, hint, "string")) return .string;
if (std.mem.eql(u8, hint, "binary")) return .bytes;
if (std.mem.eql(u8, hint, "num")) return .number;
if (std.mem.eql(u8, hint, "bool")) return .boolean;
if (std.mem.eql(u8, hint, "null")) return null;
// A byte count is a length-prefixed string; anything else is a bad hint.
if (self.declaredLength() != null) return .string;
return null;
}
};
/// The kinds of value an SRF field can hold, as `srf.Value` sees them.
pub const ValueKind = enum {
string,
number,
boolean,
bytes,
/// How to name this kind in a message aimed at someone editing the file.
pub fn describe(self: ValueKind) []const u8 {
return switch (self) {
.string => "a string",
.number => "`num`",
.boolean => "`bool`",
.bytes => "`binary`",
};
}
};
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 problem = self.lengthProblem(declared, value_start);
// Where the next field starts. When the count lands on a delimiter
// this is simply past it. When it does not, srf recovers by skipping
// the surplus bytes *and* the delimiter after them, so mirror that:
// otherwise hover would find no field where the parser reports one.
const next_field: ?usize = if (self.long_mode)
// srf discards the remainder of the line in long format, so
// nothing further on it is addressable.
null
else if (end < new_line_end and self.text[end] == ',')
end + 1
else if (problem != null)
if (std.mem.indexOfScalarPos(u8, self.text[0..new_line_end], end, ',')) |comma|
comma + 1
else
null
else
null;
return .{
.end = end,
.next_field = next_field,
.line_end = new_line_end,
.length_problem = problem,
};
}
// 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;
if (self.text[after] == '\n') return null;
if (!self.long_mode) {
if (self.text[after] == ',') return null;
} else {
// Long format tolerates whitespace, and a comment, after the value.
// Mirrors srf's `checkShortPrefix`, so hover cannot flag something
// the parser is happy with.
var i = after;
while (i < self.text.len and isSpace(self.text[i])) i += 1;
if (i >= self.text.len or self.text[i] == '\n' or self.text[i] == '#') 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.
//
// How far the surplus runs has to match how srf counts it, or hover ends
// up suggesting a different corrected length than the diagnostic does. srf
// stops at the next delimiter in compact format (`extra_bytes` in
// `checkShortPrefix`) and runs to end of line in long format, where a
// comma is ordinary text.
const line_end = self.lineEnd(after);
const surplus_end = if (self.long_mode)
line_end
else if (std.mem.indexOfScalarPos(u8, self.text[0..line_end], after, ',')) |comma|
comma
else
line_end;
return .{ .overrun = .{
.declared = declared,
.surplus_start = after,
.surplus = surplus_end - 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 "in long format, trailing whitespace after the declared bytes is accepted" {
// srf's checkShortPrefix trims whitespace before deciding, so flagging this
// would contradict the parser.
try testing.expectEqual(
@as(?LengthProblem, null),
problemAt("#!srfv1\n#!long\nk:5:hello \n", "hello"),
);
try testing.expectEqual(
@as(?LengthProblem, null),
problemAt("#!srfv1\n#!long\nk:5:hello\t\n", "hello"),
);
}
test "in long format, a trailing comment after the declared bytes is accepted" {
try testing.expectEqual(
@as(?LengthProblem, null),
problemAt("#!srfv1\n#!long\nk:5:hello # a note\n", "hello"),
);
try testing.expectEqual(
@as(?LengthProblem, null),
problemAt("#!srfv1\n#!long\nk:5:hello # spaced\n", "hello"),
);
}
test "in compact format, trailing whitespace is still an overrun" {
// Compact format has no comment-after-value allowance: the next byte must be
// a comma or the line must end.
const problem = problemAt("#!srfv1\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],
);
}
/// The value kind of the field containing the midpoint of `needle`.
fn kindAt(text: []const u8, needle: []const u8) ??ValueKind {
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.valueKind();
}
},
else => {},
};
return null;
}
test "an untyped value is a string" {
try testing.expectEqual(ValueKind.string, (kindAt("#!srfv1\nk::32\n", "k::").?).?);
}
test "an explicit string hint is a string" {
try testing.expectEqual(ValueKind.string, (kindAt("#!srfv1\nk:string:32\n", "string").?).?);
}
test "a length prefix is a string, the same as the other two forms" {
// This is what keeps srf's own README quiet: it uses `key:7:...` and
// `key::...` for the same key in one document.
try testing.expectEqual(ValueKind.string, (kindAt("#!srfv1\n#!long\nk:2:32\n", "k:2:").?).?);
}
test "num, bool and binary map to their own kinds" {
try testing.expectEqual(ValueKind.number, (kindAt("#!srfv1\nk:num:32\n", "num").?).?);
try testing.expectEqual(ValueKind.boolean, (kindAt("#!srfv1\nk:bool:true\n", "bool").?).?);
try testing.expectEqual(ValueKind.bytes, (kindAt("#!srfv1\nk:binary:aGk=\n", "binary").?).?);
}
test "a null hint carries no type evidence" {
try testing.expectEqual(@as(?ValueKind, null), kindAt("#!srfv1\nk:null:x\n", "null").?);
}
test "an empty value carries no type evidence" {
try testing.expectEqual(@as(?ValueKind, null), kindAt("#!srfv1\n#!long\nk:num:\n", "k:num:").?);
try testing.expectEqual(@as(?ValueKind, null), kindAt("#!srfv1\n#!long\nk::\n", "k::").?);
}
test "an unrecognised hint carries no type evidence, since srf reports it" {
try testing.expectEqual(@as(?ValueKind, null), kindAt("#!srfv1\nk:nmu:32\n", "nmu").?);
}
test "value kinds describe themselves for a message" {
try testing.expectEqualStrings("a string", ValueKind.string.describe());
try testing.expectEqualStrings("`num`", ValueKind.number.describe());
try testing.expectEqualStrings("`bool`", ValueKind.boolean.describe());
try testing.expectEqualStrings("`binary`", ValueKind.bytes.describe());
}
test "in compact format the surplus stops at the next delimiter, as srf counts it" {
// srf's `checkShortPrefix` reports `indexOfScalar(past_val, ',')` bytes, so
// measuring to end of line here would make hover suggest a different
// corrected length than the diagnostic does.
const problem = problemAt("#!srfv1\nim_worth:4:23,000,000,000,really:bool:false\n", "im_worth").?;
try testing.expectEqual(@as(usize, 4), problem.overrun.declared);
// "23,0" then "00" then a comma: two surplus bytes, so 4 + 2 = 6.
try testing.expectEqual(@as(usize, 2), problem.overrun.surplus);
}
test "in compact format a surplus with no delimiter runs to end of line" {
const problem = problemAt("#!srfv1\nk:2:abcdef\n", "k:2:").?;
try testing.expectEqual(@as(usize, 4), problem.overrun.surplus);
}
test "in long format the surplus runs to end of line, commas included" {
// Long format has no comma delimiter, so a comma is ordinary text and srf
// counts the whole remainder.
const problem = problemAt("#!srfv1\n#!long\nim_worth:4:23,000,000\n", "im_worth").?;
// Value is "23,0"; the remainder "00,000" is six bytes, commas and all.
try testing.expectEqual(@as(usize, 6), problem.overrun.surplus);
}