nix flake + ci
All checks were successful
Generic zig build / build (push) Successful in 55s

This commit is contained in:
Emil Lerch 2026-09-01 16:22:53 -07:00
parent 35c484460a
commit 16bc787738
Signed by: lobo
GPG key ID: A7B62D657EF764F8
10 changed files with 721 additions and 5 deletions

View file

@ -0,0 +1,34 @@
name: Generic zig build
on:
workflow_dispatch:
push:
branches:
- '*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Setup Zig
# No version given on purpose: setup-zig resolves it from
# minimum_zig_version in build.zig.zon, so the toolchain cannot drift
# from what the package declares.
uses: https://codeberg.org/mlugg/setup-zig@v2.2.1
- name: Check formatting
# Cheap and needs nothing fetched, so it runs before the build. There is
# a pre-commit hook for this too; this catches a commit that skipped it.
run: zig fmt --check .
- name: Build project
run: zig build --summary all
- name: Run tests
run: zig build test --summary all
- name: Notify
uses: https://git.lerch.org/lobo/action-notify-ntfy@v2
if: always() && env.GITEA_ACTIONS == 'true'
with:
host: ${{ secrets.NTFY_HOST }}
topic: ${{ secrets.NTFY_TOPIC }}
status: ${{ job.status }}
user: ${{ secrets.NTFY_USER }}
password: ${{ secrets.NTFY_PASSWORD }}

View file

@ -10,6 +10,14 @@ 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.
- A type-consistency warning when one key holds different value kinds in
different places in the same document, for example `close_price:num:100.00` in
most records and `close_price::200.00` in one. `k::32` is valid SRF and often
deliberate, so the check is not "this value looks numeric" but "this key is
typed differently elsewhere in this file", which the document itself evidences.
Kinds are compared by the `srf.Value` variant a hint produces, so an empty
hint, `string` and a byte count are all one kind. A uniformly mistyped document
has no minority and so gets no warning.
- 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
@ -23,6 +31,8 @@ Provides real-time parse error diagnostics using the SRF library's parser direct
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.
- A string value that also parses as a number gets a one-line note that a
consumer coercing it to a numeric field needs `strings_to_numbers`.
- 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.
@ -57,7 +67,35 @@ The binary is at `zig-out/bin/srf-lsp`.
## Install
The Neovim config below expects `srf-lsp` on `PATH`:
The Neovim config below expects `srf-lsp` on `PATH`.
### With nix
This repository is a flake. To add it to a `buildEnv`-style home profile:
```nix
inputs = {
srf-lsp.url = "git+https://git.lerch.org/lobo/srf-lsp.git";
# Optional: reuse your own nixpkgs instead of instantiating another one.
srf-lsp.inputs.nixpkgs.follows = "nixpkgs";
};
```
then add `srf-lsp.packages.${system}.default` to your package list. Or run it
directly:
```sh
nix run git+https://git.lerch.org/lobo/srf-lsp.git
```
`nix flake update srf-lsp` picks up a new version. The build runs the full test
suite in the sandbox, so a release that fails its tests will not install.
Bumping the `srf` dependency changes `build.zig.zon`, which invalidates the
`zigDeps` hash in `nix/package.nix`. To refresh it: set the hash to
`lib.fakeHash`, build, and paste the hash nix reports.
### Without nix
```sh
zig build && install -m755 zig-out/bin/srf-lsp ~/.local/bin/srf-lsp

View file

@ -1,5 +1,10 @@
const std = @import("std");
/// Single source of truth for the version. `serverInfo.version` in the LSP
/// handshake reads it through the generated `build_options` module below, so the
/// number cannot drift from the package metadata.
const build_zon = @import("build.zig.zon");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
@ -8,6 +13,9 @@ pub fn build(b: *std.Build) void {
.target = target,
});
const options = b.addOptions();
options.addOption([]const u8, "version", build_zon.version);
const exe = b.addExecutable(.{
.name = "srf-lsp",
.root_module = b.createModule(.{
@ -16,6 +24,7 @@ pub fn build(b: *std.Build) void {
.optimize = optimize,
.imports = &.{
.{ .name = "srf", .module = srf_dep.module("srf") },
.{ .name = "build_options", .module = options.createModule() },
},
}),
});

View file

@ -1,6 +1,6 @@
.{
.name = .srf_lsp,
.version = "0.0.0",
.version = "0.0.1",
.fingerprint = 0x96c04d564e5a0f32,
.minimum_zig_version = "0.16.0",
.dependencies = .{

46
flake.nix Normal file
View file

@ -0,0 +1,46 @@
{
description = "Language Server Protocol implementation for SRF (Simple Record Format)";
inputs.nixpkgs.url = "nixpkgs/master";
outputs = { self, nixpkgs }:
let
supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
# Helper to generate an attrset '{ x86_64-linux = f "x86_64-linux"; ... }'.
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
in
{
packages = forAllSystems (system:
let
pkgs = nixpkgs.legacyPackages.${system};
srf-lsp = pkgs.callPackage ./nix/package.nix {
# Pinned deliberately rather than tracking `pkgs.zig`: build.zig.zon
# declares 0.16.0 as the minimum, and 0.16 was a large standard
# library refactor, so a newer default would not build unchanged.
zig = pkgs.zig_0_16;
# The git-tracked tree, which keeps .zig-cache, zig-out and zig-pkg
# out of the derivation.
src = self;
};
in
{
inherit srf-lsp;
default = srf-lsp;
});
devShells = forAllSystems (system:
let pkgs = nixpkgs.legacyPackages.${system};
in
{
# `nix develop` for anyone without mise. The versions here are the same
# ones .mise.toml pins.
default = pkgs.mkShell {
packages = [
pkgs.zig_0_16
pkgs.zls
];
};
});
};
}

55
nix/package.nix Normal file
View file

@ -0,0 +1,55 @@
{ lib, stdenv, zig, src }:
stdenv.mkDerivation (finalAttrs: {
pname = "srf-lsp";
# Kept in step with build.zig.zon, which is also what the server reports in its
# `initialize` response (see build.zig).
version = "0.0.1";
inherit src;
__structuredAttrs = true;
strictDeps = true;
# Zig resolves build.zig.zon dependencies over the network, which the build
# sandbox forbids. `fetchDeps` runs `zig build --fetch` inside a fixed-output
# derivation, so the fetch happens once against a known hash and the real build
# stays offline.
#
# This hash changes whenever build.zig.zon changes, which in practice means
# whenever the srf dependency is bumped. To update it: set `lib.fakeHash`, run
# the build, and paste the hash nix reports.
zigDeps = zig.fetchDeps {
inherit (finalAttrs) pname version src;
fetchAll = true;
hash = "sha256-hS69otO9YY1acs+Vn8IdFj+mV8QjoeDVtxtsCrXD3RY=";
};
postConfigure = ''
# A writable copy rather than a symlink into the store: Zig writes cache
# metadata alongside the fetched packages while it verifies them.
cp -rLT ${finalAttrs.zigDeps} "$ZIG_GLOBAL_CACHE_DIR/p"
chmod -R u+w "$ZIG_GLOBAL_CACHE_DIR/p"
'';
nativeBuildInputs = [ zig ];
# The zig setup hook's check phase runs `zig build test`, which is the whole
# suite. Nothing in it touches the network or the filesystem outside the build
# directory, so it runs happily in the sandbox: every rebuild is a test run.
doCheck = true;
meta = {
description = "Language Server Protocol implementation for SRF (Simple Record Format)";
longDescription = ''
Diagnostics and hover for SRF documents, using the srf library's own parser
so the editor cannot disagree with the reference implementation. Reports
parse errors, length-prefix declarations that do not match the bytes
present, and keys whose type varies across records in one document.
'';
homepage = "https://git.lerch.org/lobo/srf-lsp";
license = lib.licenses.mit;
mainProgram = "srf-lsp";
platforms = lib.platforms.unix;
};
})

View file

@ -12,6 +12,7 @@
//! take the session down.
const std = @import("std");
const build_options = @import("build_options");
const analysis = @import("analysis.zig");
const hover = @import("hover.zig");
const positions = @import("positions.zig");
@ -263,7 +264,8 @@ fn handleInitialize(self: *Server, root: std.json.ObjectMap, id: ?std.json.Value
// and a client that took us up on pull would just collect
// MethodNotFound.
},
.serverInfo = .{ .name = "srf-lsp", .version = "0.0.1" },
// Version comes from build.zig.zon via build.zig, so it cannot drift.
.serverInfo = .{ .name = "srf-lsp", .version = build_options.version },
});
}

View file

@ -166,6 +166,7 @@ pub fn analyze(
// 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);
try appendTypeInconsistencies(allocator, &sink, text, encoding);
if (parse_error) |err| {
// Only if nothing at all was found. srf can fail without saying why:
@ -269,6 +270,123 @@ fn appendLengthProblems(
}
}
/// Warns when one key holds different value kinds in different places in the same
/// document.
///
/// This is the only diagnostic here that is not about validity. `k::32` is
/// perfectly legal SRF, and whether it is a mistake depends entirely on the
/// consumer: srf's own tests contain both `close_price::200.00` ("the shape that
/// took down a downstream consumer") and `foo::1` consumed deliberately as
/// `[]const u8`. The text cannot tell them apart, because what separates them is
/// not in the file.
///
/// What *is* in the file is how the author typed the same key elsewhere. So the
/// rule is not "this value looks numeric" (unanswerable, and noisy) but "this key
/// is `num` in thirty other places and a string here" (self-evidencing). A
/// document with one odd record out of forty is the case that sits in a cache
/// until it breaks something; a uniformly mistyped document has no minority and
/// gets no warning, which is the accepted limitation.
fn appendTypeInconsistencies(
allocator: std.mem.Allocator,
sink: *Sink,
text: []const u8,
encoding: PositionEncoding,
) error{OutOfMemory}!void {
const Stats = struct {
/// Occurrences per kind, indexed by `@intFromEnum`.
counts: [std.enums.values(document.ValueKind).len]u32 = @splat(0),
/// Breaks ties: with no majority, the first spelling in the document is
/// taken as the intended one.
first: document.ValueKind,
fn total(self: @This()) u32 {
var sum: u32 = 0;
for (self.counts) |c| sum += c;
return sum;
}
fn majority(self: @This()) document.ValueKind {
var best = self.first;
var best_count = self.counts[@intFromEnum(self.first)];
for (std.enums.values(document.ValueKind)) |kind| {
const count = self.counts[@intFromEnum(kind)];
if (count > best_count) {
best = kind;
best_count = count;
}
}
return best;
}
fn distinctKinds(self: @This()) u32 {
var seen: u32 = 0;
for (self.counts) |c| if (c > 0) {
seen += 1;
};
return seen;
}
};
// Keys are slices into `text`, which outlives this function, so there is
// nothing to duplicate. Byte-exact comparison on purpose: srf does not trim
// keys either, so `k ` and `k` really are different keys to it.
var stats: std.StringHashMapUnmanaged(Stats) = .empty;
defer stats.deinit(allocator);
var counting = document.items(text);
while (counting.next()) |item| {
const located = switch (item) {
.field => |f| f,
else => continue,
};
const kind = located.field.valueKind() orelse continue;
const entry = try stats.getOrPut(allocator, located.field.key);
if (!entry.found_existing) entry.value_ptr.* = .{ .first = kind };
entry.value_ptr.counts[@intFromEnum(kind)] += 1;
}
var emitting = document.items(text);
while (emitting.next()) |item| {
const located = switch (item) {
.field => |f| f,
else => continue,
};
const kind = located.field.valueKind() orelse continue;
const entry = stats.get(located.field.key) orelse continue;
// A key seen once proves nothing, and a consistent key is fine.
if (entry.total() < 2 or entry.distinctKinds() < 2) continue;
const expected = entry.majority();
if (kind == expected) continue;
const others = entry.counts[@intFromEnum(expected)];
const message = try std.fmt.allocPrint(
allocator,
"`{s}` is {s} in {d} other field(s) in this file, but {s} here",
.{ located.field.key, expected.describe(), others, kind.describe() },
);
errdefer allocator.free(message);
sink.items.append(allocator, .{
// Point at the hint, which is the thing that disagrees. An untyped
// field has no hint to point at, so fall back to the key.
.range = if (located.hint_span) |hs|
positions.rangeFromSpan(text, hs.start, hs.end, encoding)
else
positions.rangeFromSpan(text, located.key_span.start, located.key_span.end, encoding),
// Not a validity problem: the document parses. It is a warning that
// a consumer coercing this key will fail on this record.
.severity = .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.
@ -593,3 +711,188 @@ test "the unlocated fallback still fires when nothing else explains the failure"
try testing.expectEqual(@as(usize, 1), diags.len);
try testing.expect(std.mem.indexOf(u8, diags[0].message, "empty") != null);
}
/// Only the type-consistency warnings, without srf's own diagnostics.
fn inconsistenciesForTest(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 appendTypeInconsistencies(testing.allocator, &sink, text, .@"utf-8");
return sink.items.toOwnedSlice(testing.allocator);
}
test "a key typed num everywhere but one place warns on the odd one" {
const text =
"#!srfv1\n#!long\n" ++
"close_price:num:100.00\n\n" ++
"close_price:num:150.00\n\n" ++
"close_price:num:175.00\n\n" ++
"close_price::200.00\n";
const diags = try inconsistenciesForTest(text);
defer freeDiagnostics(testing.allocator, diags);
try testing.expectEqual(@as(usize, 1), diags.len);
try testing.expectEqual(Severity.warning, diags[0].severity);
try testing.expectEqualStrings(
"`close_price` is `num` in 3 other field(s) in this file, but a string here",
diags[0].message,
);
// The untyped field is on the last line, and with no hint to point at the
// range covers the key.
try testing.expectEqual(@as(u32, 8), diags[0].range.start.line);
try testing.expectEqual(@as(u32, 0), diags[0].range.start.character);
try testing.expectEqual(@as(u32, 11), diags[0].range.end.character);
}
test "the warning points at the hint when there is one" {
const text = "#!srfv1\n#!long\nk::abc\n\nk::def\n\nk:num:5\n";
const diags = try inconsistenciesForTest(text);
defer freeDiagnostics(testing.allocator, diags);
try testing.expectEqual(@as(usize, 1), diags.len);
// "k:" is two bytes, so the `num` hint runs from character 2 to 5.
try testing.expectEqual(@as(u32, 6), diags[0].range.start.line);
try testing.expectEqual(@as(u32, 2), diags[0].range.start.character);
try testing.expectEqual(@as(u32, 5), diags[0].range.end.character);
}
test "srf's own foo::1 fixture stays silent" {
// From srf.zig's "iterator with blank" test, where `foo` is deliberately a
// []const u8 holding digits. Consistent, so there is no signal.
const text =
"#!srfv1\n" ++
"foo::1,bar:num:42\n\n" ++
"foo::2,bar:num:24\n";
const diags = try inconsistenciesForTest(text);
defer freeDiagnostics(testing.allocator, diags);
try testing.expectEqual(@as(usize, 0), diags.len);
}
test "the README long-format example stays silent" {
// The same key appears as `:7:` and `::` here. Both are strings, so comparing
// hint text rather than value kind would flag the reference documentation.
const text =
\\#!srfv1
\\#!long
\\key::string value, with any data except a \n
\\this is a number:num: 5
\\null value:null:
\\array::array's don't exist. Use json or toml or something
\\data with newlines must have a length:7:foo
\\bar
\\boolean value:bool:false
\\
\\key::this is the second record
\\this is a number:num:42
\\null value:null:
\\array::array's still don't exist
\\data with newlines must have a length::single line
\\
;
const diags = try inconsistenciesForTest(text);
defer freeDiagnostics(testing.allocator, diags);
if (diags.len != 0) {
for (diags) |d| std.debug.print("\nunexpected: {s}\n", .{d.message});
}
try testing.expectEqual(@as(usize, 0), diags.len);
}
test "a key seen once proves nothing" {
const diags = try inconsistenciesForTest("#!srfv1\n#!long\nk::32\nother:num:5\n");
defer freeDiagnostics(testing.allocator, diags);
try testing.expectEqual(@as(usize, 0), diags.len);
}
test "a null value mixes with any type without warning" {
const text = "#!srfv1\n#!long\nk:num:5\n\nk:null:\n\nk:num:7\n";
const diags = try inconsistenciesForTest(text);
defer freeDiagnostics(testing.allocator, diags);
try testing.expectEqual(@as(usize, 0), diags.len);
}
test "an empty value mixes with any type without warning" {
const text = "#!srfv1\n#!long\nk:num:5\n\nk:num:\n\nk:num:7\n";
const diags = try inconsistenciesForTest(text);
defer freeDiagnostics(testing.allocator, diags);
try testing.expectEqual(@as(usize, 0), diags.len);
}
test "a one against one tie flags the later occurrence" {
// No majority exists, but the inconsistency is still the signal. The first
// spelling wins so the result is deterministic.
const text = "#!srfv1\n#!long\nk:num:5\n\nk::abc\n";
const diags = try inconsistenciesForTest(text);
defer freeDiagnostics(testing.allocator, diags);
try testing.expectEqual(@as(usize, 1), diags.len);
try testing.expectEqual(@as(u32, 4), diags[0].range.start.line);
try testing.expect(std.mem.indexOf(u8, diags[0].message, "is `num` in 1 other") != null);
}
test "the three string spellings are one kind, not three" {
const text = "#!srfv1\n#!long\nk::abc\n\nk:string:def\n\nk:3:ghi\n";
const diags = try inconsistenciesForTest(text);
defer freeDiagnostics(testing.allocator, diags);
try testing.expectEqual(@as(usize, 0), diags.len);
}
test "a repeated key within one compact record is checked too" {
// `to()` takes only the first occurrence, so a disagreeing duplicate matters.
const diags = try inconsistenciesForTest("#!srfv1\nk:num:1,k::2\n");
defer freeDiagnostics(testing.allocator, diags);
try testing.expectEqual(@as(usize, 1), diags.len);
}
test "bool and binary inconsistencies are reported too" {
const bools = try inconsistenciesForTest("#!srfv1\n#!long\nk:bool:true\n\nk:bool:false\n\nk::true\n");
defer freeDiagnostics(testing.allocator, bools);
try testing.expectEqual(@as(usize, 1), bools.len);
try testing.expect(std.mem.indexOf(u8, bools[0].message, "`bool`") != null);
const bins = try inconsistenciesForTest("#!srfv1\n#!long\nk:binary:aGk=\n\nk:binary:aGk=\n\nk::aGk=\n");
defer freeDiagnostics(testing.allocator, bins);
try testing.expectEqual(@as(usize, 1), bins.len);
try testing.expect(std.mem.indexOf(u8, bins[0].message, "`binary`") != null);
}
test "keys are compared byte exactly, matching srf" {
// srf does not trim keys, so `k ` and `k` are different keys to it.
const diags = try inconsistenciesForTest("#!srfv1\n#!long\nk:num:5\n\nk ::abc\n");
defer freeDiagnostics(testing.allocator, diags);
try testing.expectEqual(@as(usize, 0), diags.len);
}
test "an unrecognised hint is left to srf, not counted as a type" {
const diags = try inconsistenciesForTest("#!srfv1\n#!long\nk:num:5\n\nk:nmu:6\n");
defer freeDiagnostics(testing.allocator, diags);
try testing.expectEqual(@as(usize, 0), diags.len);
}
test "type warnings coexist with length warnings without duplication" {
const text = "#!srfv1\n#!long\nk:num:5\n\nk::abc\n\nbio:3:hello\n";
const diags = try analyzeForTest(text);
defer freeDiagnostics(testing.allocator, diags);
var type_warnings: usize = 0;
var length_warnings: usize = 0;
for (diags) |d| {
if (std.mem.indexOf(u8, d.message, "other field(s) in this file") != null) type_warnings += 1;
if (std.mem.indexOf(u8, d.message, "length prefix") != null) length_warnings += 1;
}
try testing.expectEqual(@as(usize, 1), type_warnings);
try testing.expectEqual(@as(usize, 1), length_warnings);
}
test "a consistent document produces no warnings at all" {
const text =
"#!srfv1\n" ++
"id:num:1,name::alice,active:bool:true\n" ++
"id:num:2,name::bob,active:bool:false\n";
const diags = try analyzeForTest(text);
defer freeDiagnostics(testing.allocator, diags);
try testing.expectEqual(@as(usize, 0), diags.len);
}

View file

@ -46,6 +46,54 @@ pub const Field = struct {
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 {
@ -765,3 +813,60 @@ test "the surplus of a multi-line overrun is measured on its own line" {
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());
}

View file

@ -242,9 +242,60 @@ fn describeValue(
}
try describeParsed(allocator, w, f);
try writeCoercionCaveat(allocator, w, f);
try w.print("```\n{s}\n```", .{f.value});
}
/// Notes that a string value which happens to parse as a number will be refused by
/// a consumer coercing it to a numeric field.
///
/// `k::32` is valid SRF and often deliberate, so this cannot be a diagnostic
/// without crying wolf: srf's own tests hold both `close_price::200.00` (the shape
/// that broke a downstream consumer) and `foo::1` read on purpose as
/// `[]const u8`. Hover is the right home for it. Nobody is interrupted, and the
/// note only appears when someone points at the field.
fn writeCoercionCaveat(
allocator: std.mem.Allocator,
w: *std.Io.Writer,
f: document.Field,
) std.Io.Writer.Error!void {
if (f.valueKind() != .string) return;
if (!parsesAsNumber(allocator, f)) return;
if (f.hint == null) {
try w.writeAll(
"Untyped, so this is a string: a consumer coercing it to a number needs `strings_to_numbers`.\n\n",
);
} else {
try w.writeAll(
"Declared as a string: a consumer coercing it to a number needs `strings_to_numbers`.\n\n",
);
}
}
/// Whether srf would accept this value under a `num` hint.
///
/// Answered by re-parsing with the hint swapped rather than calling
/// `std.fmt.parseFloat` here. srf's number rules depend on
/// `strict_number_parsing`, and its lenient `parseFloat` is private, so deferring
/// to the parser is the only way this cannot drift from it.
fn parsesAsNumber(allocator: std.mem.Allocator, f: document.Field) bool {
var probe = f;
probe.hint = "num";
const synthetic = buildSyntheticDocument(allocator, probe) catch return false;
defer allocator.free(synthetic);
var reader = std.Io.Reader.fixed(synthetic);
var it = srf.iterator(&reader, allocator, .{}) catch return false;
defer it.deinit();
const fields = (it.next() catch return false) orelse return false;
const field = (fields.next() catch return false) orelse return false;
const value = field.value orelse return false;
return value == .number;
}
/// 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(
@ -391,9 +442,12 @@ 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 at = std.mem.indexOf(u8, text, needle) orelse return error.NeedleNotInText;
const position = positions.positionFromByte(text, at + needle.len / 2, .@"utf-8");
const hover = (try hoverAt(testing.allocator, text, position, .@"utf-8")).?;
// An error beats a panic here: hovering punctuation legitimately returns
// null, and a test that lands there should say so rather than abort.
const hover = (try hoverAt(testing.allocator, text, position, .@"utf-8")) orelse
return error.NothingToHoverThere;
return hover.contents.value;
}
@ -680,3 +734,73 @@ test "a correct length prefix with commas in compact format reports no mismatch"
try testing.expect(std.mem.indexOf(u8, markdown, "Length mismatch") == null);
try testing.expect(std.mem.indexOf(u8, markdown, "a,b,c") != null);
}
test "an untyped numeric-looking value carries the coercion caveat" {
try expectHoverContains("#!srfv1\nk::32\n", "k::", "Untyped, so this is a string");
try expectHoverContains("#!srfv1\nk::32\n", "k::", "strings_to_numbers");
}
test "the caveat fires on the shape that broke a real consumer" {
try expectHoverContains("#!srfv1\nclose_price::200.00\n", "200.00", "strings_to_numbers");
}
test "a non-numeric string carries no caveat" {
const markdown = try hoverOn("#!srfv1\nname::alice\n", "alice");
defer testing.allocator.free(markdown);
try testing.expect(std.mem.indexOf(u8, markdown, "strings_to_numbers") == null);
}
test "an explicitly typed number carries no caveat" {
const markdown = try hoverOn("#!srfv1\nage:num:30\n", "30");
defer testing.allocator.free(markdown);
try testing.expect(std.mem.indexOf(u8, markdown, "strings_to_numbers") == null);
}
test "an explicit string hint gets the declared wording" {
try expectHoverContains("#!srfv1\nk:string:32\n", "k:string:", "Declared as a string");
}
test "a length-prefixed numeric value gets the caveat too" {
// Same coercion problem: a length prefix is still a string.
try expectHoverContains("#!srfv1\n#!long\nk:2:32\n", "k:2:", "Declared as a string");
}
test "a bool value carries no caveat" {
const markdown = try hoverOn("#!srfv1\nk:bool:true\n", "true");
defer testing.allocator.free(markdown);
try testing.expect(std.mem.indexOf(u8, markdown, "strings_to_numbers") == null);
}
test "the caveat covers negative and floating point values" {
try expectHoverContains("#!srfv1\nk::-5\n", "k::", "strings_to_numbers");
try expectHoverContains("#!srfv1\nk::1.5e3\n", "k::", "strings_to_numbers");
}
test "a multi-line length-prefixed value carries no caveat" {
const markdown = try hoverOn("#!srfv1\n#!long\nbio:7:foo\nbar\n", "foo");
defer testing.allocator.free(markdown);
try testing.expect(std.mem.indexOf(u8, markdown, "strings_to_numbers") == null);
}
test "the caveat appears on the key as well as the value" {
// Hovering either half of the field should tell the same story.
const text = "#!srfv1\nprice::32\n";
try expectHoverContains(text, "price", "strings_to_numbers");
try expectHoverContains(text, "32", "strings_to_numbers");
}
test "hovering the punctuation between key and value gives nothing" {
// The colons are not addressable, which is why the helper above reports it
// rather than dereferencing a null.
try testing.expectError(error.NothingToHoverThere, hoverOn("#!srfv1\nk::32\n", "::"));
}
test "the caveat is one line, not a paragraph" {
const markdown = try hoverOn("#!srfv1\nk::32\n", "k::");
defer testing.allocator.free(markdown);
const at = std.mem.indexOf(u8, markdown, "Untyped, so this").?;
const rest = markdown[at..];
const line_end = std.mem.indexOfScalar(u8, rest, '\n').?;
// A single sentence on a single line.
try testing.expect(line_end < 120);
}