struthio #
A small configuration language for Zig 0.17 whose programs evaluate to a Zig
struct. A program is ZON with expressions in it: it can read a context
the caller hands it, bind names with let, choose with if and switch,
compute with the usual operators, template strings with ${...}, merge
structs with ++, and call a handful of built-in functions and any the
caller adds. It cannot loop or recurse, so every program finishes, in time
proportional to its length.
const struthio = @import("struthio");
const Config = struct {
name: []const u8,
port: u16 = 8080,
tls: bool = false,
log_level: enum { debug, info } = .info,
tags: []const []const u8 = &.{},
};
const Context = struct { hostname: []const u8, prod: bool, debug: bool };
const ctx: Context = .{ .hostname = "web1", .prod = true, .debug = false };
var diag: struthio.Diagnostic = .{};
const config = struthio.evalSource(Config, gpa,
\\.{
\\ .name = "web-${ctx.hostname}",
\\ .port = if (ctx.prod) 443 else 8080,
\\ .tls = ctx.prod,
\\ .log_level = if (ctx.debug) .debug else .info,
\\ .tags = .{ "app", if (ctx.debug) "debug" },
\\}
, &ctx, .{}, &diag) catch |err| {
std.log.err("config: {f}", .{&diag}); // "line:column: message"
return err;
};
defer config.deinit();
// config.value is a Config: .{ .name = "web-web1", .port = 443, .tls = true, ... }
The API documentation is generated from the doc comments and published at https://jeff.jcollie.page/struthio/.
The name is the genus of the ostrich, which is a bird that cannot fly — as a struthio program cannot loop.
Why #
Configuration that somebody other than the program's author edits wants more than ZON — the same file for staging and production, a hostname spliced into a path — and much less than a general-purpose language. Three properties follow:
- It always finishes. There is no loop syntax and no way to define a
function, and a
letcan only name what came before it, so nothing can refer to itself. What a program builds is bounded byLimits— string length, list length, and the work of comparing and decoding — since++on a value built with++doubles it. - An error is an error, with a position. A syntax error, a type error
(
1 + "a"), a missing field or an out-of-range integer comes back as an error and aDiagnosticsayingline:column: message— orfile:///path:line:column: messagewhen the program's files have names. A decoding error names the path,.listen[1].port: -1 does not fit in u16, and points at the field in the program that wrote it. - It does no I/O. A program reads its source, its context, what the host's functions return, and the files it includes — which the host reads for it, when the program is parsed. Nothing else.
The language #
Any ZON file is a program, and decodes to what std.zon.parse would make of
it, with two exceptions: integers are 64-bit signed, so a literal that does
not fit an i64 is an error; and ${ in a string starts an interpolation
(write \$ for a literal $).
// Comments are Zig's.
let host = ctx.hostname; // `let` binds a name, once
let base = .{ .port = 8080, .tls = false };
base ++ .{ // `++` merges structs
.name = "web-${host}-${ctx.region}", // templated string
.port = if (ctx.prod) 443 else base.port,
.tls = ctx.prod and ctx.cert_path != null,
.cert = ctx.cert_path orelse "/etc/ssl/default.pem",
.log_level = if (ctx.debug) .debug else .info, // enum literal
.workers = @max(1, ctx.cpus / 2),
.tags = .{ "app", @lower(ctx.env) },
.admin = if (@contains(ctx.users, "root")) "root", // no `else`: left out
.backend = if (ctx.cache) |c| .{ .redis = c.url } else .memory,
.pool = switch (ctx.cpus) { // ranges and lists
1...4 => 8,
5...16, 32 => 32,
else => 64,
},
}
Values #
| Kind | Written | Notes |
|---|---|---|
| null | null |
|
| bool | true, false |
|
| int | 42, -7, 0xff, 0o17, 0b101, 1_000, 'a' |
i64; overflow is an error |
| float | 1.5, 1e-3, 0x1p4, inf, nan |
f64 |
| string | "a\n${x}", \\ multiline lines |
escapes are Zig's plus \$ |
| enum literal | .name, .@"two words" |
an enum tag, or a union variant with no payload |
| list | .{ 1, 2, 3 } |
also a tuple, array or slice |
| struct | .{ .a = 1, .b = 2 } |
also a union: .{ .variant = payload } |
.{} is both an empty list and an empty struct.
Expressions #
From loosest to tightest, as in Zig:
| Operators | |
|---|---|
or |
bool operands only, short-circuit |
and |
bool operands only, short-circuit |
== != < <= > >= |
do not chain; == compares deeply, and 1 == 1.0 |
orelse |
the left side unless it is null |
+ - ++ |
++ joins strings, joins lists, and merges structs (right side wins) |
* / % |
/ truncates on ints; % takes the sign of the left side |
! - |
prefix |
x.name x[i] x.? x.len |
field, index, unwrap an optional, length of a string or list |
There is no truthiness: if and the boolean operators want a bool.
if (cond) a else b is an expression whose branches extend as far right as
they can. if (optional) |x| a else b binds x to the value when it is not
null. As the value of a struct field or a list item, an if may leave out
its else, and when its condition is false the field or item is left out —
so the struct's default applies, or the list is one shorter.
switch (x) { cases => value, ..., else => value } chooses the first prong
with a case that matches, trying them in the order written; else is taken
only when none does, wherever it is written. A case is a value, matched with
==, or an inclusive range lo...hi, matched with <= at both ends, and a
prong may list several separated by commas. A prong can capture with |name|
after its =>: matched by its tag, a tagged union from the context gives its
payload, and anything else gives the value switched on.
switch (ctx.backend) {
.memory => "none",
.redis => |r| r.url,
}
A switch that matches nothing, with no else, is an error — except as the
value of a struct field or a list item, where, like an if without else, it
leaves the field or item out. The same literal written as two cases is a
syntax error; nothing else about the cases is checked until the switch is
evaluated, since a program's values have no types to check them against.
In a multiline \\ string, interpolation works and escapes do not; a literal
${ there is ${"$"}{.
Names #
ctxis the caller's context.let name = expr;at the start of a program bindsnamefor everything after it. Names cannot be rebound or shadowed, as in Zig.|name|in anifbinds for thethenbranch, and in aswitchprong for that prong.
Every let is evaluated, in order. One that fails is only an error if it is
used, so let cert = ctx.cert.?; is harmless in a program that reads cert
only when ctx.cert != null.
Built-in functions #
| Function | |
|---|---|
@lower(s), @upper(s), @trim(s) |
ASCII case; trims spaces, tabs and line ends |
@len(x) |
bytes of a string, items of a list, fields of a struct |
@contains(hay, needle) |
substring of a string, or member of a list |
@startsWith(s, p), @endsWith(s, p) |
|
@join(list, sep) |
items written as ${} would write them |
@replace(s, from, to), @split(s, sep) |
|
@min(...), @max(...) |
of two or more numbers or strings, or of one list |
@int(x), @float(x), @string(x) |
conversions; @int truncates and parses "0x10" |
@include("path") |
the value of another file; see below |
Including files #
@include("path") stands for the value of the program in another file:
let defaults = @include("defaults.struthio");
defaults ++ .{ .port = 8443 }
The included file is a program of its own. It sees ctx and the host's
functions, but none of the names of the file that includes it, and its own
lets are not seen outside it.
Files are named by file: URIs, and the path is a string literal
holding a URI reference, resolved against the URI of the file it is written
in. So it is usually a relative path — defaults.struthio,
../common/tls.struthio — but it can be an absolute one, /etc/app/base.struthio,
or a whole URI, file:///etc/app/base.struthio. Whatever it resolves to must
be a file: URI with an empty host or localhost. A program with no filename
of its own can include only absolute paths and URIs, having nothing to resolve
a relative path against.
A file is read and parsed once however many times it is included, and a file that includes itself, directly or not, is a syntax error.
Files are read by the host, when the program is parsed, so a program
evaluated many times reads them once. A host that gives no way to read them
does not support @include, and a program using it does not parse.
Using it from Zig #
Program.parse parses once; Program.eval(T, gpa, &ctx, &diag) evaluates
against a context and decodes the result into T. evalSource does both.
T may be struthio.Value for the undecoded result, and a field of type
Value takes whatever the program wrote there.
The context is a pointer to anything, read by reflection only as far as
the program reads it: ints, floats, bools, enums, optionals, []const u8
strings, slices, arrays and tuples, structs, tagged unions, and pointers to
any of these. A field of a type the language has no value for — an
allocator, a function — is an error only if a program reads it, so a context
can be a program's real state. Pass {} for no context.
Decoding follows ZON: a field left out takes its default or is an error,
an unknown field is an error, integers are range-checked, and an int is
accepted for a float. The result lives in its own arena, strings included,
and outlives the program and the context; deinit frees it.
Host functions are called like built-ins:
fn env(call: *struthio.Call, args: []const struthio.Value) struthio.EvalError!struthio.Value {
if (args[0] != .string) return call.fail("@env needs a string", .{});
const map: *const Env = @ptrCast(@alignCast(call.context.?));
return if (map.get(args[0].string)) |v| .{ .string = v } else .null;
}
const functions = [_]struthio.Function{
.{ .name = "env", .min_args = 1, .max_args = 1, .context = &my_env, .call = env },
};
var program: struthio.Program = try .parse(gpa, source, .{ .functions = &functions }, &diag);
Names and argument counts are checked when parsing. A host function that
needs the context asks for it with call.ctx().
Which fields were read. Program.evalReads and evalSourceReads take
one more argument, a *struthio.ContextReads(Context) — a
std.EnumSet of the context's top-level fields — and fill it in with the
fields the program read, whether it succeeded or failed:
var reads: struthio.ContextReads(Context) = .empty;
const config = try struthio.evalSourceReads(Config, gpa, source, &ctx, .{}, &reads, &diag);
if (!reads.contains(.foreground)) {
// No other value of ctx.foreground can change the result, as long as
// the other fields stay as they are.
}
A field counts only if evaluating the program read it: in the branch of an
if or a switch that was taken, in an included file, or through a let
that was used. A let nothing uses reads nothing, even one that failed.
Reading ctx other than as ctx.name — let c = ctx;, @string(ctx) —
reads every field, and so does a host function calling call.ctx().
Filenames are file: URIs. Options.filename names the program's own
file, so that diagnostics name it and relative includes have something to be
resolved against. It must be a file: URI with an empty host or localhost
— a bare path, another scheme or another machine fails the parse with
error.InvalidFilename. zig-uri, which struthio uses for this and
re-exports as struthio.uri, makes one from a path:
const main = try struthio.uri.Uri.fromPath(gpa, "/etc/app/main.struthio", .{});
defer main.deinit();
const filename = try main.toText(gpa, .{}); // file:///etc/app/main.struthio
Including files needs a reader from the host, Options.include. It is
always handed a file: URI in one spelling — file:///path, with an empty
host, no query or fragment, and . and .. resolved — and returns the file's
contents or an error; allocating them from the arena it is given ties them to
the program's lifetime. Where the files come from, and which paths to refuse,
is up to it:
fn read(context: ?*anyopaque, arena: std.mem.Allocator, file_uri: []const u8) anyerror![]const u8 {
const io: *const std.Io = @ptrCast(@alignCast(context.?));
const u = try struthio.uri.Uri.parse(arena, file_uri);
const path = try u.toPath(arena, .{});
return std.Io.Dir.cwd().readFileAlloc(io.*, path, arena, .limited(1 << 20));
}
var program: struthio.Program = try .parse(gpa, source, .{
.filename = filename,
.include = .{ .context = @constCast(&io), .read = read },
}, &diag);
A file that cannot be read fails the parse with error.Include, and the
diagnostic names its URI and the error.
Limits (Options.limits) bound expression nesting, the length of any
string or list, the number of values comparison and decoding may visit, and
the number of different files a program may include.
The defaults are generous for configuration; tighten them for programs from
less trusted hands.
Formatting #
struthio fmt lays programs out the way zig fmt lays out Zig, so that a
struthio file sitting beside Zig looks like it belongs there:
$ struthio fmt config.struthio conf/ # rewrite in place; names what changed
$ struthio fmt --check . # names what is not formatted, and fails
$ struthio fmt --stdin < a.struthio # formatted to standard output
A directory is searched for *.struthio files, leaving out anything whose name
starts with .. A file is replaced all at once, keeping its permissions, and
only if formatting changed it. A program that does not parse is reported as
file:line:column: error: message and left alone.
The rules are Zig's:
-
Four spaces of indent, single spaces around binary operators and after commas, and none inside parentheses or brackets.
-
The trailing comma decides. A struct or list literal, a call's arguments, and a switch's prongs go on one line —
.{ .a = 1, .b = 2 }— unless the last of them is followed by a comma. Then each goes on a line of its own, with a comma after it. A comment inside, or anything inside that cannot be on one line, has the same effect, and so does a trailing comma after a switch prong's cases. -
A line break written after a binary operator is kept, with what follows indented once more. One written anywhere else is not.
-
A multiline string starts on a line of its own, one indent in, and what follows it starts on the next:
.motd = \\Welcome to ${ctx.hostname}. , -
Comments stay where they were: at the end of the line they were on, or on a line of their own. A run of blank lines becomes one.
Strings, numbers and names are written as they were, and so is everything
inside a ${ }. Formatting changes only the space between tokens and the
trailing commas, never what a program means. The fuzz targets check exactly
that: every program the parser accepts formats to one that parses to the same
tree and evaluates to the same value, and formatting it again changes nothing.
From Zig it is struthio.format, which needs no context or host functions,
and accepts any function name:
var diag: struthio.Diagnostic = .{};
const formatted = try struthio.format(gpa, source, .{}, &diag);
defer gpa.free(formatted);
struthio.outline parses the same way and returns the program as a tree of
Spans — a byte range, what kind of thing it is (a literal, a call, a field,
a name, and so on), the span it sits inside, and the name it binds or calls,
if any — which is what the language server folds, selects and finds names
by. What is inside a string's ${ } is outlined too, inside the string's
span:
const spans = try struthio.outline(gpa, source, .{}, &diag);
defer gpa.free(spans);
For a program that does not parse yet, struthio.Tokenizer splits source
into tokens, as the parser does.
Building and testing #
The development shell has Zig 0.17.0 and the rest of the tooling:
$ nix develop
$ zig build test --summary all # unit, golden and fuzz-seed tests
$ zig build run -- fmt --check . # the struthio command, built and run
$ zig build run -- lsp # the language server, on stdio
$ zig build fuzz --fuzz # Zig's coverage-guided fuzzer
$ zig build fuzz-run -- --seconds 300 # a reproducible fuzzing loop
$ zig build docs-serve # read the API docs at localhost:8000
$ nix build # the package, tests run in the sandbox
Its one dependency is zig-uri, with z46 beneath it. zig build fetches them; the Nix package is handed them through
build.zig.zon.nix, which zon2nix --17 --nix=build.zig.zon.nix build.zig.zon regenerates whenever build.zig.zon changes.
tests/golden/ holds whole programs beside the ZON they must evaluate to
(.zon) or the diagnostic they must fail with (.err). The ones that
evaluate are also checked to be formatted already.
Editor support #
struthio lsp is a language server: an editor starts it and talks to it with
the Language Server Protocol over its standard input and output. It
reports what is wrong with each open program as you type, formats a program
as struthio fmt does, folds it, and grows a selection outward
one expression at a time. It knows where each name is bound, so it can say
what a name is when you hover over it, jump to where it is bound, find every
place it is used, rename it, complete names as you type them, and warn about a
let that nothing uses.
The diagnostics come from the real parser, so they cover more than the
grammar: a name used but never bound, a name bound twice, a file that cannot
be included. An @include is read from the editor if that file is open there,
and from disk if not, and every open program is checked again whenever any of
them changes. A mistake inside an included file is reported on the first line
of the program that includes it, naming the file, line and column.
Folding covers each struct or list literal, function call, switch and
multiline string that spans more than one line, and each run of two or more
comment lines. A fold ends on the line before the closing bracket, so the
bracket stays visible. Growing a selection goes from a name, to the
expression it is in, to the field, to the literal, and so on out to the whole
program. Both need the program to parse; while it does not, only comments
fold and a selection stays where it is.
Hovering over a name bound by let shows the let, with the comment on the
lines just above it, so a comment written there documents the name. Hovering
over a |capture| says what it holds, over a built-in function says what it
does, and over ctx, inf or nan says what they are.
Going to the definition of a name jumps to the let or |capture| that binds
it, and going to the definition of an @include opens the file it includes.
Highlights and references find every place a name is written, or every call
of a function. Renaming a name bound by let or |capture| changes every
place it is written, and is refused if the new name is already in use where
the old one is seen, since a name cannot be bound twice; a new name that is
not an identifier, or is a keyword, is written as @"...". The outline of a
program lists its lets and, nested inside them, the fields of their struct
literals. Names inside a string's ${ } count like any other. All of these
need the program to parse.
A let that nothing uses is a warning, which an editor may show by fading
it, with a quick fix that removes it. An unknown name gets a quick fix for
each name in scope that is spelled almost the same — hsot for host — or
for ctx.
Completion does not, since a program being typed seldom parses. After @ it
offers the built-in functions, with what each does, and the host functions
called anywhere in the open programs. Elsewhere it offers the let names and
captures in scope where the cursor is, ctx, the constants and the keywords.
It offers nothing in a comment, in a string's text, or after a ., where a
field's name goes. Inside a built-in function's parentheses, signature help
shows its parameters, with the one being typed marked.
Two things it cannot know, and so does not check:
- The host's functions. Which
@names a program may call depends on the program that runs it, so every@namein a file is taken to be a function taking any number of arguments. A misspelled function name is not caught. - What a program evaluates to. That depends on the context it is given,
so nothing is evaluated: a type error such as
1 + "a"shows up only when the program runs. For the same reason the fields ofctxare not known, and nothing is completed afterctx..
tree-sitter-struthio/ is a tree-sitter grammar for
struthio programs, files ending .struthio, with queries for highlighting
(highlights.scm), scopes (locals.scm) and folding (folds.scm). It parses
everything the real parser accepts to the same shape, and is a little more
lenient than it about meaning — an if without else anywhere, a name that is
not defined — so that a program being edited still has a tree.
The generated parser is committed under tree-sitter-struthio/src/, which is
where editors that build grammars from a repository look for it. Nix builds it
into a shared library with the queries beside it:
$ nix build .#tree-sitter-struthio
$ ls result
parser queries tree-sitter.json
Neovim #
vim.filetype.add({ extension = { struthio = "struthio" } })
vim.treesitter.language.add("struthio", { path = "/path/to/result/parser" })
-- and result/queries/*.scm copied to queries/struthio/ on the runtimepath
-- The language server. Neovim 0.11 or later.
vim.lsp.config("struthio", {
cmd = { "struthio", "lsp" },
filetypes = { "struthio" },
root_markers = { ".git" },
})
vim.lsp.enable("struthio")
-- Folding from the language server.
vim.api.nvim_create_autocmd("FileType", {
pattern = "struthio",
callback = function()
vim.wo.foldmethod = "expr"
vim.wo.foldexpr = "v:lua.vim.lsp.foldexpr()"
end,
})
Neovim's own mappings then reach most of it: K hovers, CTRL-] goes to a
definition, grr lists references, grn renames, gra offers the quick
fixes, gO lists the outline, CTRL-X CTRL-O completes, CTRL-S in insert
mode shows a signature, and vim.lsp.buf.format() formats. Neovim 0.12 also uses the server's selection
ranges: in visual mode, an grows the selection one level and in shrinks
it. Completion as you type, and highlighting the name under the cursor, are
for you to turn on:
vim.api.nvim_create_autocmd("LspAttach", {
callback = function(args)
local client = vim.lsp.get_client_by_id(args.data.client_id)
if client.name ~= "struthio" then return end
vim.lsp.completion.enable(true, client.id, args.buf, { autotrigger = true })
vim.api.nvim_create_autocmd({ "CursorHold", "CursorHoldI" }, {
buffer = args.buf,
callback = vim.lsp.buf.document_highlight,
})
vim.api.nvim_create_autocmd("CursorMoved", {
buffer = args.buf,
callback = vim.lsp.buf.clear_references,
})
end,
})
Without the language server, formatprg does the formatting instead. gggqG
then formats the whole buffer; it has to be the whole buffer, since a few lines
of a program are not a program:
vim.api.nvim_create_autocmd("FileType", {
pattern = "struthio",
callback = function() vim.bo.formatprg = "struthio fmt --stdin" end,
})
Helix #
Helix (25.07 or later) wants the language, where its grammar comes from, and
the queries. The first two go in ~/.config/helix/languages.toml:
[[language]]
name = "struthio"
scope = "source.struthio"
file-types = ["struthio"]
comment-tokens = ["//"]
indent = { tab-width = 4, unit = " " }
language-servers = ["struthio"]
auto-format = true
[language-server.struthio]
command = "struthio"
args = ["lsp"]
[[grammar]]
name = "struthio"
source = { git = "https://git.jcollie.dev/jeff/struthio.git", rev = "<commit>", subpath = "tree-sitter-struthio" }
rev is the commit to build the grammar from; for a checkout of your own,
source = { path = "/path/to/struthio/tree-sitter-struthio" } takes its place.
The language server needs struthio on the PATH. It formats on save, and
:format formats by hand. To format without it, replace language-servers
with formatter = { command = "struthio", args = ["fmt", "--stdin"] }.
Helix completes and shows signatures as you type, hovers with space k, goes
to a definition with gd, lists references with gr, renames with space r,
offers the quick fixes with space a, lists the outline with space s, and
selects every place the name under the cursor is written with space h. It grows a selection (Alt-o) by its
tree-sitter grammar rather than the language server, and does not fold.
The queries are not part of the grammar as far as Helix is concerned. They
live in its runtime directory under the language's name, and they are the ones
in queries/helix/, not queries/:
$ mkdir -p ~/.config/helix/runtime/queries/struthio
$ cp tree-sitter-struthio/queries/helix/*.scm ~/.config/helix/runtime/queries/struthio/
$ mkdir -p ~/.config/helix/runtime/grammars
$ hx --grammar fetch # only for a `git` source
$ hx --grammar build
$ hx --health struthio
...
Tree-sitter parser: ✓
Highlight queries: ✓
queries/helix/ holds the same patterns as queries/ with the captures
renamed to Helix's scopes. A Helix theme has no number, boolean or
property, for instance, and colors those as constant.numeric,
constant.builtin.boolean and variable.other.member instead. A capture with
a name the theme does not know is left uncolored.
Three things that trip this up:
hx --grammar buildshells out tocc, which a NixOS system has no reason to provide. Run it fromnix developin a struthio checkout, which has a compiler. Alternatively, skip the build entirely: copy the parser Nix built to where Helix would have put it,cp result/parser ~/.config/helix/runtime/grammars/struthio.so.runtime/grammars/has to exist. Helix links the parser into it and will not create it.- It fetches and builds every configured grammar, not just this one,
unless
use-grammars = { only = ["struthio"] }is at the top oflanguages.toml. Failures for the bundled grammars are unrelated; look for thestruthioline.
Working on the grammar #
In the development shell:
$ cd tree-sitter-struthio
$ tree-sitter generate # after changing grammar.js; commit what it writes to src/
$ ./helix-queries.sh # after changing queries/; commit what it writes to queries/helix/
$ tree-sitter test # the corpus in test/corpus/ and the highlighting test
$ nix flake check # all of that, and that src/ and queries/helix/ are up to date
The highlighting queries follow the convention that when several patterns
match a node the last one wins. Neovim, Helix since 25.07, and the
tree-sitter command line all work that way.
Where this lives #
The canonical repository is on my Forgejo instance, and Tangled carries a mirror of it:
git clone https://git.jcollie.dev/jeff/struthio.git
It is also on Radicle as
rad:z3X2SbfjxqgqQMcbe33XhyzFUe1xC, which is the only name that finds it
there — a peer-to-peer repository has no host to browse:
rad clone rad:z3X2SbfjxqgqQMcbe33XhyzFUe1xC
All three serve the same history. CI and the published documentation come from the Forgejo repository.
Report issues and send pull requests to the Tangled mirror. See CONTRIBUTING.md for how to set up, test and submit a change.
License #
MIT; see LICENSES/MIT.txt. The project follows REUSE.
References cited #
- Zig Software Foundation. Zig Language Reference 0.17.0. https://ziglang.org/documentation/0.17.0/ — the syntax, operator precedence and ZON that the language extends.
- Ollie, Jeffrey C. zig-jinja: a subset of Jinja for Zig. 2026. https://git.jcollie.dev/jeff/zig-jinja — the model for the diagnostics, the limits, and the fuzzing harness.
- Ollie, Jeffrey C. zig-uri: a URI parser and formatter for Zig. 2026.
https://git.jcollie.dev/jeff/zig-uri — parses, resolves and normalizes
the
file:URIs that name a program's files. - Berners-Lee, T., Fielding, R., and Masinter, L. Uniform Resource
Identifier (URI): Generic Syntax. RFC 3986, Internet Standard (STD 66).
Internet Engineering Task Force (IETF), 2005.
https://www.rfc-editor.org/info/rfc3986 — how the path in an
@includeis resolved against the URI of the file it is written in (§5). - Kerwin, M. The "file" URI Scheme. RFC 8089, Proposed Standard. Internet
Engineering Task Force (IETF), 2017. https://www.rfc-editor.org/info/rfc8089
— what a
file:URI is, and that an empty host andlocalhostboth mean this machine (§2). - Brunsfeld, Max, et al. Tree-sitter. https://tree-sitter.github.io/tree-sitter/
— the grammar DSL, the test corpus format and the query language that
tree-sitter-struthio/is written in. - Microsoft. Language Server Protocol Specification 3.17.
https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/
— the messages
struthio lspanswers, their framing, and how positions are counted. - JSON-RPC Working Group. JSON-RPC 2.0 Specification. 2013. https://www.jsonrpc.org/specification — the requests, notifications and error codes the protocol is built on.
- Zig 65%
- C 31%
- Nix 2%
- JavaScript 1%
- Scheme <1%
- 1 more language <1%