962 lines
37 KiB
Zig
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);
|
|
}
|