Something went wrong. Try again.
A small configuration language for Zig: ZON with let, if and templates over a context, evaluated to a struct, always finishing.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! `struthio lsp`: a language server, speaking the Language Server Protocol//! over standard input and output.//!//! Every open document is parsed whenever it or any other open document//! changes, and what the parser says is wrong is published as a diagnostic --//! with the real parser, so a name that is not defined, a name bound twice and//! a file that cannot be included are all reported, not only what the grammar//! rejects. `textDocument/formatting` lays a document out as `struthio fmt`//! would. `textDocument/foldingRange` folds literals, calls, switches,//! multiline strings and runs of comment lines, and//! `textDocument/selectionRange` grows a selection one expression at a time,//! both from `struthio.outline`. `textDocument/hover` says what a name is://! the `let` that binds it, with the comment above that, what a `|capture|`//! holds, or what a function does. `textDocument/definition` goes to where a//! name is bound, or to the file an `@include` names, and//! `textDocument/documentHighlight` and `textDocument/references` find every//! place it is written. `textDocument/rename` renames a name everywhere it//! is written, unless that would change what a name means, and//! `textDocument/documentSymbol` lists the `let`s and fields. A `let` nothing//! uses is published as a warning, and `textDocument/codeAction` offers to//! remove it, or to change an unknown name to one spelled almost the same.//! All of these need the document to parse. `textDocument/completion` and//! `textDocument/signatureHelp` do not, as a document being typed into//! seldom does: they read the tokens before the cursor.//!//! An editor does not know the functions the program that runs a document//! gives it, so every `@name` anywhere in a document is taken to be one, with//! any number of arguments. A misspelt function is therefore not caught here;//! everything else the parser checks is. Nothing is evaluated: what a program//! evaluates to depends on a context the editor does not have either.//!//! An `@include` is read from the editor when the file is open there, so that//! what is being typed is what is checked, and from disk when it is not.
const std = @import("std");const Io = std.Io;const Allocator = std.mem.Allocator;const json = std.json;
const struthio = @import("struthio");
/// Larger than any configuration file, or any message about one, has a reason/// to be.const max_message_size = 64 << 20;const max_file_size = 16 << 20;
/// How a position's `character` counts: in the units of whichever encoding/// the client and server agreed on. UTF-16 is the protocol's default, and the/// one every client supports.pub const Encoding = enum { utf8, utf16, utf32,
fn name(e: Encoding) []const u8 { return switch (e) { .utf8 => "utf-8", .utf16 => "utf-16", .utf32 => "utf-32", }; }};
pub const Position = struct { line: u32, character: u32 };pub const Range = struct { start: Position, end: Position };
/// The position of the character at `pos`, a diagnostic's one-based line and/// column, in `text`. The column counts characters as `Diagnostic.Pos` does,/// so it is walked rather than subtracted.pub fn position(text: []const u8, pos: struthio.Diagnostic.Pos, encoding: Encoding) Position { var line: u32 = 1; var i: usize = 0; while (line < pos.line and i < text.len) : (i += 1) { if (text[i] == '\n') line += 1; } var character: u32 = 0; var column: u32 = 1; while (i < text.len and text[i] != '\n') : (i += 1) { const c = text[i]; if (c & 0xc0 == 0x80) { // A continuation byte: part of the character before it. if (encoding == .utf8) character += 1; continue; } if (column == pos.column) break; column += 1; character += switch (encoding) { .utf8, .utf32 => 1, // Four bytes is a character outside the Basic Multilingual Plane, // which UTF-16 writes as a surrogate pair. .utf16 => if (c >= 0xf0) 2 else 1, }; } return .{ .line = line - 1, .character = character };}
/// Where `text` ends.pub fn end(text: []const u8, encoding: Encoding) Position { var line: u32 = 1; for (text) |c| { if (c == '\n') line += 1; } return position(text, .{ .line = line, .column = std.math.maxInt(u32) }, encoding);}
/// Converts between byte offsets in a text and protocol positions, which/// count lines and then units of the agreed encoding.pub const LineIndex = struct { text: []const u8, encoding: Encoding, /// The offset each line starts at. starts: []const u32,
/// Allocated from `arena`, and freed with it. pub fn init(arena: Allocator, text: []const u8, encoding: Encoding) !LineIndex { var starts: std.ArrayList(u32) = .empty; try starts.append(arena, 0); for (text, 0..) |c, i| { if (c == '\n') try starts.append(arena, @intCast(i + 1)); } return .{ .text = text, .encoding = encoding, .starts = starts.items }; }
/// The position of byte `offset`. pub fn position(self: LineIndex, at: u32) Position { const off = @min(at, self.text.len); // The last line starting at or before `off`. var lo: usize = 0; var hi: usize = self.starts.len; while (hi - lo > 1) { const mid = lo + (hi - lo) / 2; if (self.starts[mid] <= off) lo = mid else hi = mid; } var character: u32 = 0; for (self.text[self.starts[lo]..off]) |c| character += self.units(c); return .{ .line = @intCast(lo), .character = character }; }
/// The byte offset of `pos`. A line past the end is the end of the text, /// and a character past the end of its line is the end of the line, as /// the protocol says; one in the middle of a character is its start. pub fn offset(self: LineIndex, pos: Position) u32 { if (pos.line >= self.starts.len) return @intCast(self.text.len); var i: usize = self.starts[pos.line]; var character: u32 = 0; while (i < self.text.len and self.text[i] != '\n') { const n = std.unicode.utf8ByteSequenceLength(self.text[i]) catch 1; var width: u32 = 0; for (self.text[i..@min(i + n, self.text.len)]) |c| width += self.units(c); if (character + width > pos.character) break; character += width; i += @min(n, self.text.len - i); } return @intCast(i); }
/// How many units byte `c` adds, counted at the byte that starts a /// character, as `position` does. fn units(self: LineIndex, c: u8) u32 { return switch (self.encoding) { .utf8 => 1, .utf32 => if (c & 0xc0 == 0x80) 0 else 1, .utf16 => if (c & 0xc0 == 0x80) 0 else if (c >= 0xf0) 2 else 1, }; }
pub fn range(self: LineIndex, start: u32, stop: u32) Range { return .{ .start = self.position(start), .end = self.position(stop) }; }};
/// Where a document can be folded: each literal, call, switch and multiline/// string over more than one line, and each run of two or more lines that/// are only comments. A fold stops on the line before a closing bracket, so/// that the bracket stays in sight. If the document does not parse, only the/// comments are folded.pub fn foldingRanges(arena: Allocator, text: []const u8) ![]const FoldingRange { const index: LineIndex = try .init(arena, text, .utf8); var folds: std.ArrayList(FoldingRange) = .empty; var diag: struthio.Diagnostic = .{}; if (struthio.outline(arena, text, .{}, &diag)) |spans| { var last_start: ?u32 = null; for (spans) |span| { const closed = switch (span.kind) { .literal, .call, .@"switch" => true, .multiline_string => false, else => continue, }; const first = index.position(span.start).line; const last = index.position(span.end).line - @intFromBool(closed); // The outermost of several starting on one line, which comes // first: one line can only start one fold that an editor shows. if (last <= first or last_start == first) continue; last_start = first; try folds.append(arena, .{ .startLine = first, .endLine = last }); } } else |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.Syntax => {}, }
var run: ?u32 = null; for (0..index.starts.len + 1) |n| { const line: u32 = @intCast(n); const comment = line < index.starts.len and isComment(text, index, line); if (comment) { if (run == null) run = line; } else if (run) |first| { if (line - 1 > first) try folds.append(arena, .{ .startLine = first, .endLine = line - 1, .kind = "comment" }); run = null; } } std.mem.sort(FoldingRange, folds.items, {}, struct { fn lessThan(_: void, a: FoldingRange, b: FoldingRange) bool { return a.startLine < b.startLine; } }.lessThan); return folds.items;}
fn isComment(text: []const u8, index: LineIndex, line: u32) bool { const start = index.starts[line]; const stop = if (line + 1 < index.starts.len) index.starts[line + 1] else text.len; return std.mem.startsWith(u8, std.mem.trimStart(u8, text[start..stop], " \t"), "//");}
/// For each of `positions`, the spans of the document holding it, innermost/// first, as a chain of parents. If the document does not parse, each is a/// range of nothing at its position.pub fn selectionRanges(arena: Allocator, text: []const u8, encoding: Encoding, positions: []const Position) ![]const SelectionRange { const index: LineIndex = try .init(arena, text, encoding); const result = try arena.alloc(SelectionRange, positions.len); var diag: struthio.Diagnostic = .{}; const spans = struthio.outline(arena, text, .{}, &diag) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.Syntax => { for (result, positions) |*r, p| r.* = .{ .range = .{ .start = p, .end = p } }; return result; }, }; for (result, positions) |*r, p| { const off = index.offset(p); // The innermost span holding the offset is the last that does. Its // end counts, so that a cursor just after a name selects the name. var innermost: ?u32 = null; for (spans, 0..) |span, i| { if (span.start <= off and off <= span.end) innermost = @intCast(i); } var chain: std.ArrayList(Range) = .empty; var at = innermost; while (at) |i| : (at = spans[i].parent) { const range = index.range(spans[i].start, spans[i].end); // A span the same as the one inside it, as `(x)` is not, but a // name alone as the value of a `let` is, adds nothing to select. if (chain.items.len > 0 and std.meta.eql(chain.items[chain.items.len - 1], range)) continue; try chain.append(arena, range); } if (chain.items.len == 0) { r.* = .{ .range = .{ .start = p, .end = p } }; continue; } // Linked from the outside in, so that each parent exists before the // range that points to it. var parent: ?*const SelectionRange = null; var k = chain.items.len; while (k > 1) { k -= 1; const node = try arena.create(SelectionRange); node.* = .{ .range = chain.items[k], .parent = parent }; parent = node; } r.* = .{ .range = chain.items[0], .parent = parent }; } return result;}
/// A document's text, outlined, with its lines indexed: what every request/// about a name starts from. Null when the document does not parse.const Outlined = struct { text: []const u8, index: LineIndex, spans: []const struthio.Span,
fn init(arena: Allocator, text: []const u8, encoding: Encoding) !?Outlined { var diag: struthio.Diagnostic = .{}; const spans = struthio.outline(arena, text, .{}, &diag) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.Syntax => return null, }; return .{ .text = text, .index = try .init(arena, text, encoding), .spans = spans }; }
fn range(self: Outlined, start: u32, stop: u32) Range { return self.index.range(start, stop); }};
/// What a name stands for.const Target = union(enum) { /// What a `let`, an `if` or a prong binds, by the index of that span. bound: u32, /// A function, by its name without the `@`, and the index of the call. function: struct { name: []const u8, call: u32 }, /// `ctx`, `inf` or `nan`. constant: []const u8,};
/// A name written at `start..end`, and what it stands for.const Found = struct { start: u32, end: u32, target: Target };
/// The name at byte offset `off`, or null if there is none there, or none/// that anything binds.fn find(o: Outlined, off: u32) ?Found { const spans = o.spans; // A name that a span binds or calls, which is not a span of its own. for (spans, 0..) |span, i| { const n = span.name orelse continue; if (off < n.start or off >= n.end) continue; const target: Target = switch (span.kind) { .call => .{ .function = .{ .name = o.text[n.start + 1 .. n.end], .call = @intCast(i) } }, .let, .@"if", .prong => .{ .bound = @intCast(i) }, else => return null, }; return .{ .start = n.start, .end = n.end, .target = target }; }
// A name used as a value, which is a span: the innermost holding `off`. var innermost: ?u32 = null; for (spans, 0..) |span, i| { if (span.start <= off and off < span.end) innermost = @intCast(i); } const r = innermost orelse return null; const ref = spans[r]; if (ref.kind != .name) return null; const name = unquote(o.text[ref.start..ref.end]); for ([_][]const u8{ "ctx", "inf", "nan" }) |c| { if (std.mem.eql(u8, name, c)) return .{ .start = ref.start, .end = ref.end, .target = .{ .constant = c } }; } const b = binding(o.text, spans, r, name) orelse return null; return .{ .start = ref.start, .end = ref.end, .target = .{ .bound = b } };}
/// Where a name is written, and whether that is where it is bound.const Occurrence = struct { start: u32, end: u32, binds: bool };
/// Every place `target` is written: for a bound name, where it is bound and/// each use of it; for a function, each call of it; for a constant, each use.fn occurrences(arena: Allocator, o: Outlined, target: Target) ![]const Occurrence { var found: std.ArrayList(Occurrence) = .empty; switch (target) { .bound => |b| { const n = o.spans[b].name.?; const name = unquote(o.text[n.start..n.end]); try found.append(arena, .{ .start = n.start, .end = n.end, .binds = true }); for (o.spans, 0..) |span, i| { if (span.kind != .name or !std.mem.eql(u8, unquote(o.text[span.start..span.end]), name)) continue; if (binding(o.text, o.spans, @intCast(i), name) != b) continue; try found.append(arena, .{ .start = span.start, .end = span.end, .binds = false }); } }, .function => |f| for (o.spans) |span| { const n = span.name orelse continue; if (span.kind != .call or !std.mem.eql(u8, o.text[n.start + 1 .. n.end], f.name)) continue; try found.append(arena, .{ .start = n.start, .end = n.end, .binds = false }); }, .constant => |c| for (o.spans) |span| { if (span.kind != .name or !std.mem.eql(u8, unquote(o.text[span.start..span.end]), c)) continue; try found.append(arena, .{ .start = span.start, .end = span.end, .binds = false }); }, } // The binding comes first above, but is not always first in the text: a // capture is written after the condition that may use another name. std.mem.sortUnstable(Occurrence, found.items, {}, struct { fn lessThan(_: void, a: Occurrence, b: Occurrence) bool { return a.start < b.start; } }.lessThan); return found.items;}
/// What to show for the name at `pos`, or null when there is nothing to say/// or the document does not parse.pub fn hover(arena: Allocator, text: []const u8, encoding: Encoding, pos: Position) !?Hover { const o = try Outlined.init(arena, text, encoding) orelse return null; const f = find(o, o.index.offset(pos)) orelse return null; var out: Io.Writer.Allocating = .init(arena); const w = &out.writer; switch (f.target) { .function => |x| try describeFunction(w, x.name), .bound => |b| switch (o.spans[b].kind) { .let => try describeLet(w, text, o.index, o.spans[b]), else => try describeCapture(w, text, o.spans, b), }, .constant => |c| try w.writeAll(if (std.mem.eql(u8, c, "ctx")) "```struthio\nctx\n```\nThe context the program is evaluated against: whatever the program that runs it hands in." else if (std.mem.eql(u8, c, "inf")) "```struthio\ninf\n```\nPositive infinity, a float." else "```struthio\nnan\n```\nNot a number, a float."), } return .{ .contents = .{ .value = out.written() }, .range = o.range(f.start, f.end) };}
/// Where the name at `pos` is bound, as a range in the same document; or,/// for `@include("path")`, the path, which `Server` resolves to a file.pub const Definition = union(enum) { here: Range, include: []const u8,};
/// Where what is at `pos` is defined, or null if it is not a name bound in/// the document or an `@include`, or the document does not parse.pub fn definition(arena: Allocator, text: []const u8, encoding: Encoding, pos: Position) !?Definition { const o = try Outlined.init(arena, text, encoding) orelse return null; const off = o.index.offset(pos); const f = find(o, off) orelse { // The path of an `@include`, which is its call's one argument. var innermost: ?u32 = null; for (o.spans, 0..) |span, i| { if (span.start <= off and off < span.end) innermost = @intCast(i); } const arg = o.spans[innermost orelse return null]; if (arg.kind != .expression or text[arg.start] != '"') return null; const call = o.spans[arg.parent orelse return null]; if (call.kind != .call or !std.mem.eql(u8, text[call.name.?.start..call.name.?.end], "@include")) return null; return .{ .include = text[arg.start..arg.end] }; }; return switch (f.target) { .bound => |b| .{ .here = o.range(o.spans[b].name.?.start, o.spans[b].name.?.end) }, .function => |x| if (std.mem.eql(u8, x.name, "include")) blk: { const arg = nthChild(o.spans, x.call, 0) orelse break :blk null; const a = o.spans[arg]; if (a.kind != .expression or text[a.start] != '"') break :blk null; break :blk .{ .include = text[a.start..a.end] }; } else null, .constant => null, };}
pub const DocumentHighlight = struct { range: Range, /// 2 for a use, 3 for where a name is bound, as the protocol numbers /// them; 1 for a function's name, which is neither. kind: u8,};
/// Every place the name at `pos` is written, or null if there is no name/// there or the document does not parse.pub fn highlights(arena: Allocator, text: []const u8, encoding: Encoding, pos: Position) !?[]const DocumentHighlight { const o = try Outlined.init(arena, text, encoding) orelse return null; const f = find(o, o.index.offset(pos)) orelse return null; const found = try occurrences(arena, o, f.target); const result = try arena.alloc(DocumentHighlight, found.len); for (found, result) |x, *h| h.* = .{ .range = o.range(x.start, x.end), .kind = switch (f.target) { .bound => if (x.binds) 3 else 2, .function => 1, .constant => 2, }, }; return result;}
/// The ranges of every place the name at `pos` is written, the place it is/// bound included only if `declaration` is. Names are bound in the file that/// uses them, so every reference is in this document.pub fn references(arena: Allocator, text: []const u8, encoding: Encoding, pos: Position, declaration: bool) !?[]const Range { const o = try Outlined.init(arena, text, encoding) orelse return null; const f = find(o, o.index.offset(pos)) orelse return null; var result: std.ArrayList(Range) = .empty; for (try occurrences(arena, o, f.target)) |x| { if (x.binds and !declaration) continue; try result.append(arena, o.range(x.start, x.end)); } return result.items;}
/// The names in scope at a place in a document, read from its tokens rather/// than its outline, since a document being typed into seldom parses.const Scope = struct { /// Each `let` whose `;` comes before the place, by its whole text. lets: std.ArrayList(struct { name: []const u8, source: []const u8 }) = .empty, /// Each capture whose body the place is in, innermost last. captures: std.ArrayList(Capture) = .empty,
const Capture = struct { name: []const u8, depth: u32, of_if: bool };
/// Forget the captures whose body ends at a `,`, `;` or closing bracket /// at `depth`. fn end(self: *Scope, depth: u32) void { while (self.captures.items.len > 0 and self.captures.items[self.captures.items.len - 1].depth >= depth) { _ = self.captures.pop(); } }};
/// The names in scope at byte offset `at` in `text`, or null if `at` is in a/// comment or in a string's text, where no name can go.////// Brackets are counted, and a capture is in scope from its `|name|` until/// the `,`, `;` or closing bracket that ends what it is in, or the `else` of/// its `if`. A `let` is in scope after its `;`.fn scopeAt(arena: Allocator, text: []const u8, at: u32) !?Scope { const items = try tokensBefore(arena, text, at) orelse return null; var scope: Scope = .{}; var depth: u32 = 0; var pending: ?struct { name: []const u8, start: u32 } = null; for (items, 0..) |tok, k| switch (tok.tag) { .keyword_let => if (k + 1 < items.len and isName(items[k + 1].tag)) { pending = .{ .name = text[items[k + 1].start..items[k + 1].end], .start = tok.start }; }, .semicolon => { if (pending) |p| try scope.lets.append(arena, .{ .name = p.name, .source = text[p.start..tok.end] }); pending = null; scope.end(depth); }, .comma => scope.end(depth), .l_paren, .l_brace, .l_bracket, .period_brace => depth += 1, .r_paren, .r_brace, .r_bracket => { scope.end(depth); depth -|= 1; }, .pipe => if (k >= 1 and k + 2 < items.len and isName(items[k + 1].tag) and items[k + 2].tag == .pipe) { const before = items[k - 1].tag; if (before != .r_paren and before != .equal_angle_bracket_right) continue; try scope.captures.append(arena, .{ .name = text[items[k + 1].start..items[k + 1].end], .depth = depth, .of_if = before == .r_paren, }); }, .keyword_else => { // The innermost `if` capture at this depth, and what is inside it. var c = scope.captures.items.len; while (c > 0) : (c -= 1) { const x = scope.captures.items[c - 1]; if (x.depth < depth) break; if (x.of_if and x.depth == depth) { scope.captures.shrinkRetainingCapacity(c - 1); break; } } }, else => {}, }; return scope;}
/// The tokens of `text` before byte offset `at`, or null if `at` is in a/// comment or in a string's text. Inside a string, only an unclosed `${`/// counts: its tokens follow a `{` standing in for the `${`, whose `$` is/// just before it. A string broken by a line end is skipped to that line/// end, and a byte that is not a token is skipped.fn tokensBefore(arena: Allocator, text: []const u8, at: u32) !?[]const struthio.Tokenizer.Token { var toks: std.ArrayList(struthio.Tokenizer.Token) = .empty; var t: struthio.Tokenizer = .{ .source = text, .index = 0, .limit = at }; var last_end: u32 = 0; while (true) { const tok = t.next(); switch (tok.tag) { .eof => break, // A string that is not closed before `at`, or a `\\` line that // runs into it: `at` is in its text, or in an unclosed `${`. .invalid, .multiline_string_line => |tag| { const quoted = text[tok.start] == '"'; const lined = tag == .multiline_string_line and tok.end == at; if (!quoted and !lined) { if (tag == .invalid) t.index = tok.start + 1 else try toks.append(arena, tok); last_end = t.index; continue; } var i = tok.start + if (quoted) @as(u32, 1) else 2; const open = while (i < at) { switch (text[i]) { '\n' => break null, '\\' => i += if (quoted) 2 else 1, '$' => if (i + 1 < at and text[i + 1] == '{') { i = (struthio.Tokenizer.skipInterpolation(text, i + 2, at, 0) orelse break i + 2) + 1; } else { i += 1; }, else => i += 1, } } else null; if (open) |inside| { // Read what is in the `${` as code, inside a bracket. try toks.append(arena, .{ .tag = .l_brace, .start = inside - 1, .end = inside }); t.index = inside; } else if (i < at and text[i] == '\n') { // A string broken by a line end: carry on after it. t.index = i + 1; } else return null; }, else => try toks.append(arena, tok), } last_end = tok.end; } // A comment runs to `at`. if (std.mem.find(u8, text[last_end..at], "//") != null) return null; return toks.items;}
fn isName(tag: struthio.Tokenizer.Tag) bool { return tag == .identifier or tag == .quoted_identifier;}
pub const CompletionItem = struct { label: []const u8, /// As the protocol numbers them: 3 a function, 6 a variable, 14 a /// keyword, 21 a constant. kind: u8, detail: ?[]const u8 = null, documentation: ?struct { kind: []const u8 = "markdown", value: []const u8 } = null, /// For a function: `@name` replaces the `@` and what follows it, which an /// editor would not take to be part of the word. filterText: ?[]const u8 = null, textEdit: ?TextEdit = null,};
/// What can be written at `pos`: after `@`, the built-ins and `functions`,/// the host functions open documents call; elsewhere, the names in scope,/// the constants and the keywords. Nothing in a comment, a string's text, or/// after a `.`, where a field's name goes and the fields are not known./// `functions` may hold duplicates and built-ins, which are left out.pub fn completions(arena: Allocator, text: []const u8, encoding: Encoding, pos: Position, functions: []const []const u8) ![]const CompletionItem { const index: LineIndex = try .init(arena, text, encoding); const off = index.offset(pos); var word = off; while (word > 0 and (std.ascii.isAlphanumeric(text[word - 1]) or text[word - 1] == '_')) word -= 1; if (word < off and std.ascii.isDigit(text[word])) return &.{};
var items: std.ArrayList(CompletionItem) = .empty; if (word > 0 and text[word - 1] == '@') { _ = try scopeAt(arena, text, word - 1) orelse return &.{}; const replace = index.range(word - 1, off); for (builtin_docs) |doc| { const label = try std.fmt.allocPrint(arena, "@{s}", .{doc.name}); try items.append(arena, .{ .label = label, .kind = 3, .detail = doc.signature, .documentation = .{ .value = doc.description }, .filterText = label, .textEdit = .{ .range = replace, .newText = label }, }); } var seen: std.StringHashMapUnmanaged(void) = .empty; for (functions) |name| { if (isBuiltin(name) or (try seen.getOrPut(arena, name)).found_existing) continue; const label = try std.fmt.allocPrint(arena, "@{s}", .{name}); try items.append(arena, .{ .label = label, .kind = 3, .detail = "host function", .filterText = label, .textEdit = .{ .range = replace, .newText = label }, }); } return items.items; } if (word > 0 and text[word - 1] == '.') return &.{};
const scope = try scopeAt(arena, text, word) orelse return &.{}; for (scope.lets.items) |let| { try items.append(arena, .{ .label = let.name, .kind = 6, .detail = abbreviate(let.source) }); } for (scope.captures.items) |c| { try items.append(arena, .{ .label = c.name, .kind = 6, .detail = try std.fmt.allocPrint(arena, "|{s}|", .{c.name}) }); } try items.append(arena, .{ .label = "ctx", .kind = 6, .detail = "the context" }); for ([_][]const u8{ "true", "false", "null", "inf", "nan" }) |c| try items.append(arena, .{ .label = c, .kind = 21 }); for ([_][]const u8{ "if", "switch", "else", "orelse", "and", "or" }) |k| try items.append(arena, .{ .label = k, .kind = 14 }); return items.items;}
fn isBuiltin(name: []const u8) bool { for (builtin_docs) |doc| if (std.mem.eql(u8, doc.name, name)) return true; return false;}
/// Every `@name` called in `text` but the one whose `@` is at `skip`: the/// one being typed, which is not yet a name anything calls.fn calledFunctions(arena: Allocator, out: *std.ArrayList([]const u8), text: []const u8, skip: ?usize) !void { var i: usize = 0; while (std.mem.findScalarPos(u8, text, i, '@')) |at| { var j = at + 1; while (j < text.len and (std.ascii.isAlphanumeric(text[j]) or text[j] == '_')) j += 1; if (j > at + 1 and !std.ascii.isDigit(text[at + 1]) and at != skip) try out.append(arena, text[at + 1 .. j]); i = j; }}
/// Where a name bound by span `binder` can be used, by byte offsets: after a/// `let`, to the end of the program; in an `if`, from its `|name|` to its/// `else`; in a prong, from its `|name|` to the prong's end.fn scopeOf(o: Outlined, binder: u32) struct { start: u32, end: u32 } { const span = o.spans[binder]; return switch (span.kind) { .let => .{ .start = span.end, .end = o.spans[0].end }, .@"if" => .{ .start = span.name.?.end, .end = if (nthChild(o.spans, binder, 2)) |e| o.spans[e].start else span.end, }, else => .{ .start = span.name.?.end, .end = span.end }, };}
/// The range of the name at `pos`, if it is one that can be renamed: one a/// `let`, an `if` or a prong binds.pub fn prepareRename(arena: Allocator, text: []const u8, encoding: Encoding, pos: Position) !?Range { const o = try Outlined.init(arena, text, encoding) orelse return null; const f = find(o, o.index.offset(pos)) orelse return null; if (f.target != .bound) return null; return o.range(f.start, f.end);}
pub const RenameError = error{ /// Not a name bound in the document, or the document does not parse. NotRenameable, /// The new name is `ctx`, `inf` or `nan`. Reserved, /// The new name is already in scope where the name is bound, or written /// where the name is in scope. Conflict, /// The new name has a line end or a control character in it. BadName,};
/// The edits that rename the name at `pos` to `new_name`, wherever it is/// written. The new name is written as an identifier if it can be, and as/// `@"..."` if not.pub fn rename(arena: Allocator, text: []const u8, encoding: Encoding, pos: Position, new_name: []const u8) (RenameError || Allocator.Error)![]const TextEdit { const o = try Outlined.init(arena, text, encoding) orelse return error.NotRenameable; const f = find(o, o.index.offset(pos)) orelse return error.NotRenameable; const binder = switch (f.target) { .bound => |b| b, else => return error.NotRenameable, }; const name = unquote(new_name); for (name) |c| if (c < 0x20 or c == 0x7f) return error.BadName; if (name.len == 0) return error.BadName; for ([_][]const u8{ "ctx", "inf", "nan" }) |r| if (std.mem.eql(u8, name, r)) return error.Reserved;
const n = o.spans[binder].name.?; if (!std.mem.eql(u8, name, unquote(text[n.start..n.end]))) { const scope = scopeOf(o, binder); for (o.spans, 0..) |span, i| { const written = if (span.kind == .name) NameSpan{ .start = span.start, .end = span.end } else switch (span.kind) { .let, .@"if", .prong => span.name orelse continue, else => continue, }; if (!std.mem.eql(u8, unquote(text[written.start..written.end]), name)) continue; // Written where the renamed name would be seen instead. if (written.start >= scope.start and written.start < scope.end) return error.Conflict; // Bound where it is seen where the renamed name is bound. if (span.kind != .name) { const other = scopeOf(o, @intCast(i)); if (n.start >= other.start and n.start < other.end) return error.Conflict; } } }
const spelled = try spell(arena, name); const found = try occurrences(arena, o, f.target); const edits = try arena.alloc(TextEdit, found.len); for (found, edits) |x, *e| e.* = .{ .range = o.range(x.start, x.end), .newText = spelled }; return edits;}
const NameSpan = struthio.NameSpan;
/// `name` as an identifier, or as `@"..."` if it is not one.fn spell(arena: Allocator, name: []const u8) ![]const u8 { const plain = (std.ascii.isAlphabetic(name[0]) or name[0] == '_') and for (name) |c| { if (!std.ascii.isAlphanumeric(c) and c != '_') break false; } else true; if (plain) { var t: struthio.Tokenizer = .init(name); if (t.next().tag == .identifier) return name; } var out: std.ArrayList(u8) = .empty; try out.appendSlice(arena, "@\""); for (name) |c| { if (c == '"' or c == '\\') try out.append(arena, '\\'); try out.append(arena, c); } try out.append(arena, '"'); return out.items;}
pub const SignatureHelp = struct { signatures: []const Signature, activeSignature: u32 = 0, activeParameter: u32,
pub const Signature = struct { label: []const u8, documentation: struct { kind: []const u8 = "markdown", value: []const u8 }, parameters: []const Parameter, }; /// A parameter by where it is in its signature's `label`. pub const Parameter = struct { label: [2]u32 };};
/// The built-in whose arguments `pos` is among, which of them it is at, and/// what each is; or null if it is not in a built-in's arguments. A document/// being typed into seldom parses, so this reads the tokens before `pos`.pub fn signatureHelp(arena: Allocator, text: []const u8, encoding: Encoding, pos: Position) !?SignatureHelp { const index: LineIndex = try .init(arena, text, encoding); const toks = try tokensBefore(arena, text, index.offset(pos)) orelse return null; const Frame = struct { call: ?[]const u8 = null, commas: u32 = 0, interpolation: bool = false }; var frames: std.ArrayList(Frame) = .empty; for (toks, 0..) |tok, k| switch (tok.tag) { .l_paren => try frames.append(arena, .{ .call = if (k > 0 and toks[k - 1].tag == .builtin) text[toks[k - 1].start + 1 .. toks[k - 1].end] else null, }), .l_brace => try frames.append(arena, .{ .interpolation = tok.start > 0 and text[tok.start - 1] == '$' }), .period_brace, .l_bracket => try frames.append(arena, .{}), .r_paren, .r_brace, .r_bracket => _ = frames.pop(), .comma => if (frames.items.len > 0) { frames.items[frames.items.len - 1].commas += 1; }, else => {}, }; // The innermost call, unless a string's `${` is nearer: what is in it is // not an argument. var k = frames.items.len; const call = while (k > 0) : (k -= 1) { const x = frames.items[k - 1]; if (x.interpolation) return null; if (x.call != null) break x; } else return null; const doc = for (builtin_docs) |d| { if (std.mem.eql(u8, d.name, call.call.?)) break d; } else return null;
var signatures: std.ArrayList(SignatureHelp.Signature) = .empty; var lines = std.mem.splitScalar(u8, doc.signature, '\n'); var active: u32 = 0; while (lines.next()) |label| { var params: std.ArrayList(SignatureHelp.Parameter) = .empty; const open: u32 = @intCast(std.mem.findScalar(u8, label, '(').? + 1); const close: u32 = @intCast(std.mem.findScalarLast(u8, label, ')').?); var start = open; var i = open; while (i <= close) : (i += 1) { if (i == close or label[i] == ',') { if (i > start) try params.append(arena, .{ .label = .{ start, i } }); start = i + 2; } } // `@min(list)` is the form for one argument, `@min(a, b, ...)` for // more. if (params.items.len == 1 and call.commas == 0 and signatures.items.len > 0) active = @intCast(signatures.items.len); try signatures.append(arena, .{ .label = label, .documentation = .{ .value = doc.description }, .parameters = params.items }); } const params = signatures.items[active].parameters; const variadic = params.len > 0 and std.mem.eql(u8, signatures.items[active].label[params[params.len - 1].label[0]..params[params.len - 1].label[1]], "..."); var param = call.commas; if (param >= params.len) param = if (variadic) @intCast(params.len - 1) else param; return .{ .signatures = signatures.items, .activeSignature = active, .activeParameter = param };}
pub const DocumentSymbol = struct { name: []const u8, /// As the protocol numbers them: 13 a variable, 8 a field. kind: u8, range: Range, selectionRange: Range, children: []const DocumentSymbol,};
/// The `let`s and the fields of the struct literals, each field inside the/// `let` or field whose value it is in; or null if the document does not/// parse.pub fn documentSymbols(arena: Allocator, text: []const u8, encoding: Encoding) !?[]const DocumentSymbol { const o = try Outlined.init(arena, text, encoding) orelse return null; // Each symbol's children, by the index of its span; the file's are the // top level. const children = try arena.alloc(std.ArrayList(u32), o.spans.len); @memset(children, .empty); for (o.spans, 0..) |span, i| { if (span.kind != .let and span.kind != .field) continue; var up = span.parent; const owner: u32 = while (up) |u| : (up = o.spans[u].parent) { const kind = o.spans[u].kind; if (kind == .let or kind == .field or kind == .file) break u; } else 0; try children[owner].append(arena, @intCast(i)); } return try symbols(arena, o, children, 0);}
fn symbols(arena: Allocator, o: Outlined, children: []const std.ArrayList(u32), of: u32) ![]const DocumentSymbol { const list = children[of].items; const result = try arena.alloc(DocumentSymbol, list.len); for (list, result) |i, *sym| { const span = o.spans[i]; const n = span.name.?; sym.* = .{ .name = unquote(o.text[n.start..n.end]), .kind = if (span.kind == .let) 13 else 8, .range = o.range(span.start, span.end), .selectionRange = o.range(n.start, n.end), .children = try symbols(arena, o, children, i), }; } return result;}
/// Each `let` that nothing uses, by the index of its span.fn unusedLets(arena: Allocator, o: Outlined) ![]const u32 { var unused: std.ArrayList(u32) = .empty; for (o.spans, 0..) |span, i| { if (span.kind != .let) continue; if ((try occurrences(arena, o, .{ .bound = @intCast(i) })).len == 1) try unused.append(arena, @intCast(i)); } return unused.items;}
/// A warning for each `let` that nothing uses, marked unnecessary so that an/// editor can fade it; none if the document does not parse.pub fn unusedWarnings(arena: Allocator, text: []const u8, encoding: Encoding) ![]const LspDiagnostic { const o = try Outlined.init(arena, text, encoding) orelse return &.{}; var result: std.ArrayList(LspDiagnostic) = .empty; for (try unusedLets(arena, o)) |i| { const n = o.spans[i].name.?; try result.append(arena, .{ .range = o.range(n.start, n.end), .severity = 2, .message = try std.fmt.allocPrint(arena, "'{s}' is never used", .{unquote(text[n.start..n.end])}), .tags = &.{1}, }); } return result.items;}
pub const CodeAction = struct { title: []const u8, kind: []const u8 = "quickfix", isPreferred: bool = false, edits: []const TextEdit,};
/// The quick fixes for what is in `range`: removing a `let` that nothing/// uses, and, for each name in `unknown` -- the names the parser said it did/// not know, by where they start -- changing it to a name in scope that is/// spelled almost the same.pub fn codeActions(arena: Allocator, text: []const u8, encoding: Encoding, range: Range, unknown: []const Position) ![]const CodeAction { const index: LineIndex = try .init(arena, text, encoding); const lo = index.offset(range.start); const hi = index.offset(range.end); var actions: std.ArrayList(CodeAction) = .empty;
if (try Outlined.init(arena, text, encoding)) |o| for (try unusedLets(arena, o)) |i| { const span = o.spans[i]; if (span.end < lo or span.start > hi) continue; // The whole line, when the `let` is all that is on it. var start = span.start; while (start > 0 and (text[start - 1] == ' ' or text[start - 1] == '\t')) start -= 1; var stop = span.end; while (stop < text.len and (text[stop] == ' ' or text[stop] == '\t' or text[stop] == '\r')) stop += 1; if ((start == 0 or text[start - 1] == '\n') and stop < text.len and text[stop] == '\n') { stop += 1; } else { // Sharing its line: the `let` and the space after it. start = span.start; stop = span.end; while (stop < text.len and (text[stop] == ' ' or text[stop] == '\t')) stop += 1; } const n = span.name.?; try actions.append(arena, .{ .title = try std.fmt.allocPrint(arena, "Remove the unused '{s}'", .{unquote(text[n.start..n.end])}), .isPreferred = true, .edits = try arena.dupe(TextEdit, &.{.{ .range = index.range(start, stop), .newText = "" }}), }); };
for (unknown) |p| { const at = index.offset(p); var t: struthio.Tokenizer = .{ .source = text, .index = at, .limit = @intCast(text.len) }; const tok = t.next(); if (!isName(tok.tag) or tok.start != at) continue; const wrong = unquote(text[tok.start..tok.end]); const scope = try scopeAt(arena, text, at) orelse continue; var names: std.ArrayList([]const u8) = .empty; for (scope.lets.items) |l| try names.append(arena, l.name); for (scope.captures.items) |c| try names.append(arena, c.name); try names.append(arena, "ctx"); for (names.items) |written| { const name = unquote(written); if (distance(wrong, name, 2) > @max(1, wrong.len / 3)) continue; try actions.append(arena, .{ .title = try std.fmt.allocPrint(arena, "Change '{s}' to '{s}'", .{ wrong, name }), .edits = try arena.dupe(TextEdit, &.{.{ .range = index.range(tok.start, tok.end), .newText = written }}), }); } } return actions.items;}
/// The edit distance between `a` and `b`, counting a swap of two adjacent/// bytes as one edit, or `limit + 1` if it is more than `limit`.fn distance(a: []const u8, b: []const u8, limit: usize) usize { if (@max(a.len, b.len) - @min(a.len, b.len) > limit or a.len > 64 or b.len > 64) return limit + 1; var rows: [3][65]usize = undefined; for (0..b.len + 1) |j| rows[0][j] = j; for (1..a.len + 1) |i| { const row = &rows[i % 3]; const prev = &rows[(i - 1) % 3]; row[0] = i; for (1..b.len + 1) |j| { const cost: usize = if (a[i - 1] == b[j - 1]) 0 else 1; row[j] = @min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + cost); if (i > 1 and j > 1 and a[i - 1] == b[j - 2] and a[i - 2] == b[j - 1]) { row[j] = @min(row[j], rows[(i - 2) % 3][j - 2] + 1); } } } return @min(rows[a.len % 3][b.len], limit + 1);}
/// The span that binds `name` where span `r` uses it: an `if` or a prong/// with that `|name|` whose body holds it, or else a `let` before it. A name/// cannot be bound twice, so the first found is the only one.fn binding(text: []const u8, spans: []const struthio.Span, r: u32, name: []const u8) ?u32 { const ref = spans[r]; var at = ref.parent; while (at) |a| : (at = spans[a].parent) { const span = spans[a]; if (span.kind != .@"if" and span.kind != .prong) continue; const n = span.name orelse continue; if (!std.mem.eql(u8, unquote(text[n.start..n.end]), name)) continue; // A capture is seen after it is written -- not in an `if`'s // condition or a prong's cases -- and in an `if` only up to its // `else`, which is its third part. if (ref.start < n.end) continue; if (span.kind == .@"if") { if (nthChild(spans, a, 2)) |e| if (ref.start >= spans[e].start) continue; } return a; } for (spans, 0..) |span, i| { if (span.kind != .let or span.end > ref.start) continue; const n = span.name.?; if (std.mem.eql(u8, unquote(text[n.start..n.end]), name)) return @intCast(i); } return null;}
/// The `n`th span directly inside span `parent`, counting from zero.fn nthChild(spans: []const struthio.Span, parent: u32, n: usize) ?u32 { var seen: usize = 0; for (spans[parent + 1 ..], parent + 1..) |span, i| { if (span.parent != parent) continue; if (seen == n) return @intCast(i); seen += 1; } return null;}
/// `@"name"` is the same name as `name`.fn unquote(name: []const u8) []const u8 { if (name.len >= 3 and name[0] == '@' and name[1] == '"' and name[name.len - 1] == '"') { return name[2 .. name.len - 1]; } return name;}
/// `let name = value;`, and the comment on the lines just above it.fn describeLet(w: *Io.Writer, text: []const u8, index: LineIndex, span: struthio.Span) !void { try w.print("```struthio\n{s}\n```", .{text[span.start..span.end]}); const line = index.position(span.start).line; var first = line; while (first > 0 and isComment(text, index, first - 1)) first -= 1; if (first == line) return; try w.writeAll("\n"); for (first..line) |l| { const comment = std.mem.trim(u8, text[index.starts[l]..index.starts[l + 1]], " \t\r\n"); const body = comment[2..]; try w.print("\n{s}", .{if (std.mem.startsWith(u8, body, " ")) body[1..] else body}); }}
/// What the `|name|` of span `i`, an `if` or a prong, holds.fn describeCapture(w: *Io.Writer, text: []const u8, spans: []const struthio.Span, i: u32) !void { const span = spans[i]; const n = span.name.?; try w.print("```struthio\n|{s}|\n```\n", .{text[n.start..n.end]}); if (span.kind == .@"if") { const cond = spans[nthChild(spans, i, 0).?]; try w.print("The value of `{s}`, which is not `null` here.", .{abbreviate(text[cond.start..cond.end])}); } else { const subject = spans[nthChild(spans, span.parent.?, 0).?]; try w.print( "What `{s}` matched: the payload of a tagged union matched by its tag, and otherwise the value itself.", .{abbreviate(text[subject.start..subject.end])}, ); }}
/// Source short enough to quote in a sentence, or `...`.fn abbreviate(source: []const u8) []const u8 { return if (source.len <= 60 and std.mem.findScalar(u8, source, '\n') == null) source else "...";}
fn describeFunction(w: *Io.Writer, name: []const u8) !void { for (builtin_docs) |doc| { if (std.mem.eql(u8, doc.name, name)) { return w.print("```struthio\n{s}\n```\n{s}", .{ doc.signature, doc.description }); } } try w.print( "```struthio\n@{s}(...)\n```\nNot a built-in: a function that the program running this file provides, which the language server cannot check.", .{name}, );}
const BuiltinDoc = struct { name: []const u8, signature: []const u8, description: []const u8 };
/// What each built-in does, as `builtins.zig` does it.const builtin_docs = [_]BuiltinDoc{ .{ .name = "lower", .signature = "@lower(s)", .description = "`s` with its ASCII letters in lower case." }, .{ .name = "upper", .signature = "@upper(s)", .description = "`s` with its ASCII letters in upper case." }, .{ .name = "trim", .signature = "@trim(s)", .description = "`s` without the spaces, tabs, carriage returns and line feeds at either end." }, .{ .name = "len", .signature = "@len(x)", .description = "The number of bytes in a string, items in a list, or fields in a struct." }, .{ .name = "contains", .signature = "@contains(hay, needle)", .description = "Whether the string `hay` has `needle` in it, or the list `hay` has an item `==` to `needle`." }, .{ .name = "startsWith", .signature = "@startsWith(s, prefix)", .description = "Whether the string `s` starts with `prefix`." }, .{ .name = "endsWith", .signature = "@endsWith(s, suffix)", .description = "Whether the string `s` ends with `suffix`." }, .{ .name = "join", .signature = "@join(list, sep)", .description = "The items of `list`, each written as `${}` would write it, with `sep` between them." }, .{ .name = "replace", .signature = "@replace(s, from, to)", .description = "`s` with every `from` in it replaced by `to`. `from` cannot be empty." }, .{ .name = "split", .signature = "@split(s, sep)", .description = "The list of the parts of `s` between each `sep`. `sep` cannot be empty." }, .{ .name = "min", .signature = "@min(a, b, ...)\n@min(list)", .description = "The least of two or more numbers or strings, or of the items of one list." }, .{ .name = "max", .signature = "@max(a, b, ...)\n@max(list)", .description = "The greatest of two or more numbers or strings, or of the items of one list." }, .{ .name = "int", .signature = "@int(x)", .description = "`x` as an integer: a float truncated toward zero, `true` as 1 and `false` as 0, or a string parsed as Zig writes an integer, so `\"0x10\"` is 16." }, .{ .name = "float", .signature = "@float(x)", .description = "`x` as a float: an integer, or a string parsed as a number." }, .{ .name = "string", .signature = "@string(x)", .description = "`x` written as `${}` would write it." }, .{ .name = "include", .signature = "@include(\"path\")", .description = "The value of the program in another file. `path` is resolved against the URI of this one, and the file is read when this one is parsed." },};
const Document = struct { /// The URI as the client spelled it, which is how it has to be named back /// to the client. uri: []const u8, /// The same, spelled as the parser spells a file it includes, or null if /// it is not a `file:` URI the parser accepts -- an unsaved buffer, say. name: ?[]const u8, text: []const u8,
fn deinit(self: Document, gpa: Allocator) void { gpa.free(self.uri); if (self.name) |n| gpa.free(n); gpa.free(self.text); }};
pub const Server = struct { gpa: Allocator, io: Io, out: *Io.Writer, state: enum { uninitialized, running, shutdown } = .uninitialized, encoding: Encoding = .utf16, /// By the URI the client gave. documents: std.StringArrayHashMapUnmanaged(Document) = .empty,
pub fn init(gpa: Allocator, io: Io, out: *Io.Writer) Server { return .{ .gpa = gpa, .io = io, .out = out }; }
pub fn deinit(self: *Server) void { for (self.documents.values()) |d| d.deinit(self.gpa); self.documents.deinit(self.gpa); }
/// Answer messages from `in` until the client says `exit` or goes away, /// and return the status to exit with: 0 after a `shutdown`, 1 without /// one, as the protocol asks. pub fn run(self: *Server, in: *Io.Reader) !u8 { while (true) { var arena_state: std.heap.ArenaAllocator = .init(self.gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); const body = try readMessage(in, arena) orelse return 1; const message = json.parseFromSliceLeaky(json.Value, arena, body, .{}) catch { try self.respondError(.null, parse_error, "not JSON"); continue; }; if (try self.handle(arena, message)) |status| return status; } }
/// Handle one message, and return an exit status if it was `exit`. fn handle(self: *Server, arena: Allocator, message: json.Value) !?u8 { if (message != .object) { try self.respondError(.null, invalid_request, "not an object"); return null; } const id = message.object.get("id"); const method = switch (message.object.get("method") orelse { // A response to a request of ours, of which there are none. return null; }) { .string => |s| s, else => { try self.respondError(id orelse .null, invalid_request, "the method is not a string"); return null; }, }; const params = message.object.get("params") orelse .null;
if (std.mem.eql(u8, method, "exit")) return if (self.state == .shutdown) 0 else 1;
if (id) |req_id| { self.request(arena, req_id, method, params) catch |err| switch (err) { error.OutOfMemory, error.WriteFailed => |e| return e, else => try self.respondError(req_id, invalid_params, @errorName(err)), }; } else { self.notification(arena, method, params) catch |err| switch (err) { error.OutOfMemory, error.WriteFailed => |e| return e, // A notification has nobody to answer; one that makes no // sense is dropped, as the protocol says. else => {}, }; } return null; }
fn request(self: *Server, arena: Allocator, id: json.Value, method: []const u8, params: json.Value) !void { if (std.mem.eql(u8, method, "initialize")) { if (self.state != .uninitialized) return self.respondError(id, invalid_request, "already initialized"); const p = try parse(InitializeParams, arena, params); if (p.capabilities.general) |general| { if (general.positionEncodings) |offered| self.encoding = choose(offered); } self.state = .running; return self.respond(id, .{ .capabilities = .{ .positionEncoding = self.encoding.name(), .textDocumentSync = .{ .openClose = true, .change = 1 }, .documentFormattingProvider = true, .foldingRangeProvider = true, .selectionRangeProvider = true, .hoverProvider = true, .definitionProvider = true, .documentHighlightProvider = true, .referencesProvider = true, .completionProvider = .{ .triggerCharacters = &[_][]const u8{"@"} }, .renameProvider = .{ .prepareProvider = true }, .signatureHelpProvider = .{ .triggerCharacters = &[_][]const u8{ "(", "," } }, .documentSymbolProvider = true, .codeActionProvider = .{ .codeActionKinds = &[_][]const u8{"quickfix"} }, }, .serverInfo = .{ .name = "struthio", .version = version }, }); } switch (self.state) { .uninitialized => return self.respondError(id, server_not_initialized, "not initialized"), .shutdown => return self.respondError(id, invalid_request, "shut down"), .running => {}, } if (std.mem.eql(u8, method, "shutdown")) { self.state = .shutdown; return self.respond(id, null); } if (std.mem.eql(u8, method, "textDocument/formatting")) { const p = try parse(struct { textDocument: struct { uri: []const u8 } }, arena, params); const doc = self.documents.get(p.textDocument.uri) orelse return self.respond(id, null); var diag: struthio.Diagnostic = .{}; const formatted = struthio.format(arena, doc.text, .{}, &diag) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, // Its diagnostics already say why. error.Syntax => return self.respond(id, null), }; if (std.mem.eql(u8, formatted, doc.text)) return self.respond(id, &[_]TextEdit{}); return self.respond(id, &[_]TextEdit{.{ .range = .{ .start = .{ .line = 0, .character = 0 }, .end = end(doc.text, self.encoding) }, .newText = formatted, }}); } if (std.mem.eql(u8, method, "textDocument/foldingRange")) { const p = try parse(struct { textDocument: struct { uri: []const u8 } }, arena, params); const doc = self.documents.get(p.textDocument.uri) orelse return self.respond(id, null); return self.respond(id, try foldingRanges(arena, doc.text)); } if (std.mem.eql(u8, method, "textDocument/selectionRange")) { const p = try parse(struct { textDocument: struct { uri: []const u8 }, positions: []const Position, }, arena, params); const doc = self.documents.get(p.textDocument.uri) orelse return self.respond(id, null); return self.respond(id, try selectionRanges(arena, doc.text, self.encoding, p.positions)); } if (std.mem.eql(u8, method, "textDocument/hover")) { const p = try parse(struct { textDocument: struct { uri: []const u8 }, position: Position, }, arena, params); const doc = self.documents.get(p.textDocument.uri) orelse return self.respond(id, null); const h = try hover(arena, doc.text, self.encoding, p.position) orelse return self.respond(id, null); return self.respond(id, h); } if (std.mem.eql(u8, method, "textDocument/definition")) { const p = try parse(TextDocumentPosition, arena, params); const doc = self.documents.get(p.textDocument.uri) orelse return self.respond(id, null); const d = try definition(arena, doc.text, self.encoding, p.position) orelse return self.respond(id, null); return switch (d) { .here => |r| self.respond(id, Location{ .uri = doc.uri, .range = r }), .include => |path| { const target = try self.resolve(arena, doc.uri, path) orelse return self.respond(id, null); const zero: Position = .{ .line = 0, .character = 0 }; return self.respond(id, Location{ .uri = target, .range = .{ .start = zero, .end = zero } }); }, }; } if (std.mem.eql(u8, method, "textDocument/completion")) { const p = try parse(TextDocumentPosition, arena, params); const doc = self.documents.get(p.textDocument.uri) orelse return self.respond(id, null); // The `@` just typed, if it was, is not a call of anything. const index: LineIndex = try .init(arena, doc.text, self.encoding); var at = index.offset(p.position); while (at > 0 and (std.ascii.isAlphanumeric(doc.text[at - 1]) or doc.text[at - 1] == '_')) at -= 1; var functions: std.ArrayList([]const u8) = .empty; for (self.documents.values()) |other| { const skip: ?usize = if (other.text.ptr == doc.text.ptr and at > 0) at - 1 else null; try calledFunctions(arena, &functions, other.text, skip); } return self.respond(id, try completions(arena, doc.text, self.encoding, p.position, functions.items)); } if (std.mem.eql(u8, method, "textDocument/prepareRename")) { const p = try parse(TextDocumentPosition, arena, params); const doc = self.documents.get(p.textDocument.uri) orelse return self.respond(id, null); return self.respond(id, try prepareRename(arena, doc.text, self.encoding, p.position)); } if (std.mem.eql(u8, method, "textDocument/rename")) { const p = try parse(struct { textDocument: struct { uri: []const u8 }, position: Position, newName: []const u8, }, arena, params); const doc = self.documents.get(p.textDocument.uri) orelse return self.respond(id, null); const edits = rename(arena, doc.text, self.encoding, p.position, p.newName) catch |err| { return self.respondError(id, request_failed, switch (err) { error.OutOfMemory => return error.OutOfMemory, error.NotRenameable => "only a name bound by let or |capture| can be renamed", error.Reserved => "ctx, inf and nan are reserved", error.Conflict => "that name is already used where this one is seen", error.BadName => "a name cannot be empty, or have a line end or control character in it", }); }; return self.respond(id, try workspaceEdit(arena, doc.uri, edits)); } if (std.mem.eql(u8, method, "textDocument/signatureHelp")) { const p = try parse(TextDocumentPosition, arena, params); const doc = self.documents.get(p.textDocument.uri) orelse return self.respond(id, null); return self.respond(id, try signatureHelp(arena, doc.text, self.encoding, p.position)); } if (std.mem.eql(u8, method, "textDocument/documentSymbol")) { const p = try parse(struct { textDocument: struct { uri: []const u8 } }, arena, params); const doc = self.documents.get(p.textDocument.uri) orelse return self.respond(id, null); return self.respond(id, try documentSymbols(arena, doc.text, self.encoding)); } if (std.mem.eql(u8, method, "textDocument/codeAction")) { const p = try parse(struct { textDocument: struct { uri: []const u8 }, range: Range, context: struct { diagnostics: []const struct { range: Range, message: []const u8 } = &.{} } = .{}, }, arena, params); const doc = self.documents.get(p.textDocument.uri) orelse return self.respond(id, null); var unknown: std.ArrayList(Position) = .empty; for (p.context.diagnostics) |d| { if (std.mem.startsWith(u8, d.message, "unknown name ")) try unknown.append(arena, d.range.start); } const actions = try codeActions(arena, doc.text, self.encoding, p.range, unknown.items); const Action = struct { title: []const u8, kind: []const u8, isPreferred: bool, edit: WorkspaceEdit, }; const result = try arena.alloc(Action, actions.len); for (actions, result) |a, *r| r.* = .{ .title = a.title, .kind = a.kind, .isPreferred = a.isPreferred, .edit = try workspaceEdit(arena, doc.uri, a.edits), }; return self.respond(id, result); } if (std.mem.eql(u8, method, "textDocument/documentHighlight")) { const p = try parse(TextDocumentPosition, arena, params); const doc = self.documents.get(p.textDocument.uri) orelse return self.respond(id, null); return self.respond(id, try highlights(arena, doc.text, self.encoding, p.position)); } if (std.mem.eql(u8, method, "textDocument/references")) { const p = try parse(struct { textDocument: struct { uri: []const u8 }, position: Position, context: struct { includeDeclaration: bool = true } = .{}, }, arena, params); const doc = self.documents.get(p.textDocument.uri) orelse return self.respond(id, null); const ranges = try references(arena, doc.text, self.encoding, p.position, p.context.includeDeclaration) orelse return self.respond(id, null); const locations = try arena.alloc(Location, ranges.len); for (ranges, locations) |r, *l| l.* = .{ .uri = doc.uri, .range = r }; return self.respond(id, locations); } return self.respondError(id, method_not_found, "no such method"); }
fn notification(self: *Server, arena: Allocator, method: []const u8, params: json.Value) !void { if (self.state != .running) return; if (std.mem.eql(u8, method, "textDocument/didOpen")) { const p = try parse(struct { textDocument: struct { uri: []const u8, text: []const u8 } }, arena, params); try self.put(p.textDocument.uri, p.textDocument.text); try self.diagnoseAll(arena); } else if (std.mem.eql(u8, method, "textDocument/didChange")) { const p = try parse(struct { textDocument: struct { uri: []const u8 }, contentChanges: []const struct { text: []const u8 }, }, arena, params); // Synchronized in full, so the last change is the whole text. if (p.contentChanges.len == 0) return; if (!self.documents.contains(p.textDocument.uri)) return; try self.put(p.textDocument.uri, p.contentChanges[p.contentChanges.len - 1].text); try self.diagnoseAll(arena); } else if (std.mem.eql(u8, method, "textDocument/didClose")) { const p = try parse(struct { textDocument: struct { uri: []const u8 } }, arena, params); const kv = self.documents.fetchOrderedRemove(p.textDocument.uri) orelse return; defer kv.value.deinit(self.gpa); try self.publish(kv.value.uri, &.{}); // What included it now reads it from disk instead. try self.diagnoseAll(arena); } }
/// Open `uri` with `text`, or replace what it had. fn put(self: *Server, uri: []const u8, text: []const u8) !void { const owned_text = try self.gpa.dupe(u8, text); errdefer self.gpa.free(owned_text); const gop = try self.documents.getOrPut(self.gpa, uri); if (gop.found_existing) { self.gpa.free(gop.value_ptr.text); gop.value_ptr.text = owned_text; return; } errdefer self.documents.orderedRemoveAt(gop.index); const owned_uri = try self.gpa.dupe(u8, uri); errdefer self.gpa.free(owned_uri); gop.key_ptr.* = owned_uri; gop.value_ptr.* = .{ .uri = owned_uri, .name = try self.normalize(uri), .text = owned_text }; }
/// `uri` as the parser names a file it includes, which is how an /// `@include` is matched to an open document. The parser itself does the /// spelling, so the two cannot disagree about it. fn normalize(self: *Server, uri: []const u8) !?[]const u8 { var diag: struthio.Diagnostic = .{}; var program = struthio.Program.parse(self.gpa, "null", .{ .filename = uri }, &diag) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => return null, }; defer program.deinit(); return try self.gpa.dupe(u8, program.tree.sources[0].name.?); }
/// The URI of the file that `@include(literal)`, written in `uri`, names: /// as the client spells it if the file is open, and as the parser does if /// not. Null if the parser would refuse it. The parser does the resolving, /// so the two cannot disagree about it. fn resolve(self: *Server, arena: Allocator, uri: []const u8, literal: []const u8) !?[]const u8 { const Recorder = struct { /// Not the program's, which is gone once it is parsed. arena: Allocator, name: ?[]const u8 = null, fn read(context: ?*anyopaque, _: Allocator, file_uri: []const u8) anyerror![]const u8 { const r: *@This() = @ptrCast(@alignCast(context.?)); r.name = try r.arena.dupe(u8, file_uri); return "null"; } }; var recorder: Recorder = .{ .arena = arena }; var diag: struthio.Diagnostic = .{}; const source = try std.fmt.allocPrint(arena, "@include({s})", .{literal}); var program = struthio.Program.parse(arena, source, .{ .filename = uri, .include = .{ .context = &recorder, .read = Recorder.read }, }, &diag) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => return null, }; program.deinit(); const name = recorder.name orelse return null; for (self.documents.values()) |doc| { if (doc.name) |n| if (std.mem.eql(u8, n, name)) return doc.uri; } return name; }
fn diagnoseAll(self: *Server, arena: Allocator) !void { for (self.documents.values()) |doc| try self.diagnose(arena, doc); }
/// Parse `doc` and publish what is wrong with it, which may be nothing. fn diagnose(self: *Server, arena: Allocator, doc: Document) !void { var reader: Reader = .{ .server = self, .arena = arena }; try reader.scan(doc.text); var diag: struthio.Diagnostic = .{}; const failed = while (true) { const known = reader.functions.count(); const functions = try arena.alloc(struthio.Function, known); for (functions, reader.functions.keys()) |*f, n| f.* = .{ .name = n, .call = unevaluated }; reader.read = .empty; var program = struthio.Program.parse(self.gpa, doc.text, .{ .filename = doc.name, .functions = functions, .include = .{ .context = &reader, .read = Reader.include }, }, &diag) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, // An included file may call functions of its own, found only // when it was read: if it did, try again knowing them. error.Syntax => if (reader.functions.count() > known) continue else break true, error.Include, error.InvalidFilename => break true, }; program.deinit(); break false; }; const warnings = try unusedWarnings(arena, doc.text, self.encoding); if (!failed) return self.publish(doc.uri, warnings);
const where = diag.file(); const in_doc = if (where) |w| !reader.read.contains(w) else true; var buf: [256]u8 = undefined; const d: LspDiagnostic = if (in_doc) blk: { const start = position(doc.text, diag.pos, self.encoding); var stop = position(doc.text, .{ .line = diag.pos.line, .column = diag.pos.column +| 1 }, self.encoding); if (stop.line != start.line) stop = start; // The whole of a name, rather than its first letter. const index: LineIndex = try .init(arena, doc.text, self.encoding); const at = index.offset(start); var t: struthio.Tokenizer = .init(doc.text); t.index = at; const tok = t.next(); if (tok.start == at and isName(tok.tag)) stop = index.position(tok.end); break :blk .{ .range = .{ .start = start, .end = stop }, .message = diag.message() }; } else blk: { // The mistake is in a file it includes. Where that is included // from is not known, so the whole of this one is marked. const text = reader.read.get(where.?).?; const start = position(text, diag.pos, self.encoding); break :blk .{ .range = .{ .start = .{ .line = 0, .character = 0 }, .end = .{ .line = 0, .character = 0 } }, .message = std.fmt.bufPrint(&buf, "in {s}:{d}:{d}: {s}", .{ where.?, diag.pos.line, diag.pos.column, diag.message(), }) catch diag.message(), .relatedInformation = &.{.{ .location = .{ .uri = where.?, .range = .{ .start = start, .end = start } }, .message = diag.message(), }}, }; }; return self.publish(doc.uri, try std.mem.concat(arena, LspDiagnostic, &.{ &.{d}, warnings })); }
fn publish(self: *Server, uri: []const u8, diagnostics: []const LspDiagnostic) !void { return self.send(.{ .jsonrpc = "2.0", .method = "textDocument/publishDiagnostics", .params = .{ .uri = uri, .diagnostics = diagnostics }, }); }
fn respond(self: *Server, id: json.Value, result: anytype) !void { // `send` leaves out a field whose optional value is null, and a // response without `result` is not a response at all: an empty one // has to say `"result":null`. if (@typeInfo(@TypeOf(result)) == .optional) { if (result) |r| return self.respond(id, r); return self.respond(id, null); } return self.send(.{ .jsonrpc = "2.0", .id = id, .result = result }); }
fn respondError(self: *Server, id: json.Value, code: i32, message: []const u8) !void { return self.send(.{ .jsonrpc = "2.0", .id = id, .@"error" = .{ .code = code, .message = message } }); }
fn send(self: *Server, message: anytype) !void { var body: Io.Writer.Allocating = .init(self.gpa); defer body.deinit(); json.Stringify.value(message, .{ .emit_null_optional_fields = false }, &body.writer) catch return error.OutOfMemory; try self.out.print("Content-Length: {d}\r\n\r\n{s}", .{ body.written().len, body.written() }); try self.out.flush(); }};
/// Reads the files a document includes, for the parser, and gathers the/// names of the functions they call.const Reader = struct { server: *Server, arena: Allocator, functions: std.StringArrayHashMapUnmanaged(void) = .empty, /// Each file read for this parse, by the name the parser gave it, and its /// text, so that a diagnostic in one can be told apart from one in the /// document. read: std.StringHashMapUnmanaged([]const u8) = .empty,
/// Everything is kept in `self.arena` rather than the program's: a /// program that fails to parse is freed at once, and what it read is /// still wanted, to say where the mistake was. fn include(context: ?*anyopaque, _: Allocator, file_uri: []const u8) anyerror![]const u8 { const self: *Reader = @ptrCast(@alignCast(context.?)); const text = for (self.server.documents.values()) |doc| { if (doc.name) |n| if (std.mem.eql(u8, n, file_uri)) break doc.text; } else blk: { const u = try struthio.uri.Uri.parse(self.arena, file_uri); const path = try u.toPath(self.arena, .{}); break :blk try Io.Dir.cwd().readFileAlloc(self.server.io, path, self.arena, .limited(max_file_size)); }; try self.read.put(self.arena, try self.arena.dupe(u8, file_uri), text); try self.scan(text); return text; }
/// Take every `@name` in `text` to be a function. Strings and comments /// are not told apart, so as to find calls inside `${ }`; a name found /// that is never called costs nothing, and a built-in's name is looked up /// as the built-in first. fn scan(self: *Reader, text: []const u8) !void { var i: usize = 0; while (std.mem.findScalarPos(u8, text, i, '@')) |at| { var j = at + 1; while (j < text.len and (std.ascii.isAlphanumeric(text[j]) or text[j] == '_')) j += 1; if (j > at + 1 and !std.ascii.isDigit(text[at + 1])) { try self.functions.put(self.arena, text[at + 1 .. j], {}); } i = j; } }};
/// What a function found by `Reader.scan` does, which is nothing: a/// document is parsed and never evaluated.fn unevaluated(call: *struthio.Call, args: []const struthio.Value) struthio.EvalError!struthio.Value { _ = args; return call.fail("not evaluated by the language server", .{});}
/// The encoding to use, of those the client offered: UTF-8, which is what the/// parser counts in, if it can; then UTF-32; then the default.fn choose(offered: []const []const u8) Encoding { for ([_]Encoding{ .utf8, .utf32 }) |e| { for (offered) |o| if (std.mem.eql(u8, o, e.name())) return e; } return .utf16;}
/// The body of the next message, or null at the end of the input.fn readMessage(in: *Io.Reader, arena: Allocator) !?[]u8 { var length: ?usize = null; var first = true; while (true) : (first = false) { const line = in.takeDelimiterInclusive('\n') catch |err| switch (err) { error.EndOfStream => if (first) return null else return error.EndOfStream, else => |e| return e, }; const header = std.mem.trimEnd(u8, line, "\r\n"); if (header.len == 0) break; const colon = std.mem.findScalar(u8, header, ':') orelse return error.BadHeader; if (std.ascii.eqlIgnoreCase(header[0..colon], "content-length")) { const value = std.mem.trim(u8, header[colon + 1 ..], " \t"); length = std.fmt.parseInt(usize, value, 10) catch return error.BadHeader; } } const n = length orelse return error.BadHeader; if (n > max_message_size) return error.MessageTooLarge; return try in.readAllocAll(arena, n);}
fn parse(comptime T: type, arena: Allocator, params: json.Value) !T { return json.parseFromValueLeaky(T, arena, params, .{ .ignore_unknown_fields = true });}
const version = "0.1.0";
// JSON-RPC's error codes, and the protocol's.const parse_error = -32700;const invalid_request = -32600;const method_not_found = -32601;const invalid_params = -32602;const server_not_initialized = -32002;const request_failed = -32803;
const InitializeParams = struct { capabilities: struct { general: ?struct { positionEncodings: ?[]const []const u8 = null, } = null, } = .{},};
const TextDocumentPosition = struct { textDocument: struct { uri: []const u8 }, position: Position,};
const TextEdit = struct { range: Range, newText: []const u8 };
const Location = struct { uri: []const u8, range: Range };
/// Edits to one document, by its URI.const WorkspaceEdit = struct { changes: json.ArrayHashMap([]const TextEdit) };
fn workspaceEdit(arena: Allocator, uri: []const u8, edits: []const TextEdit) !WorkspaceEdit { var changes: json.ArrayHashMap([]const TextEdit) = .{}; try changes.map.put(arena, uri, edits); return .{ .changes = changes };}
pub const FoldingRange = struct { startLine: u32, endLine: u32, /// `"comment"` for a run of comments, and null for code. kind: ?[]const u8 = null,};
pub const Hover = struct { contents: struct { kind: []const u8 = "markdown", value: []const u8 }, range: Range,};
pub const SelectionRange = struct { range: Range, parent: ?*const SelectionRange = null,};
const LspDiagnostic = struct { range: Range, /// 1 for an error, which stops the parser, and 2 for a warning. severity: u8 = 1, source: []const u8 = "struthio", message: []const u8, /// 1, unnecessary, for what an editor may fade. tags: ?[]const u32 = null, relatedInformation: ?[]const struct { location: struct { uri: []const u8, range: Range }, message: []const u8, } = null,};
// -- tests --------------------------------------------------------------------
const testing = std.testing;
/// Messages from a client, framed.fn frame(gpa: Allocator, messages: []const []const u8) ![]u8 { var out: Io.Writer.Allocating = .init(gpa); defer out.deinit(); for (messages) |m| try out.writer.print("Content-Length: {d}\r\n\r\n{s}", .{ m.len, m }); return out.toOwnedSlice();}
/// What a server says to `messages`, one parsed reply per element, and the/// status it exits with.const Session = struct { arena: std.heap.ArenaAllocator, replies: []json.Value, status: u8,
fn run(messages: []const []const u8) !Session { const gpa = testing.allocator; const input = try frame(gpa, messages); defer gpa.free(input); var in: Io.Reader = .fixed(input); var out: Io.Writer.Allocating = .init(gpa); defer out.deinit(); var server: Server = .init(gpa, testing.io, &out.writer); defer server.deinit(); const status = try server.run(&in);
var self: Session = .{ .arena = .init(gpa), .replies = &.{}, .status = status }; errdefer self.arena.deinit(); const a = self.arena.allocator(); var replies: std.ArrayList(json.Value) = .empty; var reader: Io.Reader = .fixed(out.written()); while (try readMessage(&reader, a)) |body| { try replies.append(a, try json.parseFromSliceLeaky(json.Value, a, body, .{})); } self.replies = replies.items; return self; }
fn deinit(self: *Session) void { self.arena.deinit(); }
/// The text of reply `i`, so that a test can say what it expects as JSON. fn text(self: *Session, i: usize) ![]const u8 { return json.Stringify.valueAlloc(self.arena.allocator(), self.replies[i], .{}); }};
const initialize = \\{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{}}};const initialized = \\{"jsonrpc":"2.0","method":"initialized","params":{}};const shutdown = \\{"jsonrpc":"2.0","id":99,"method":"shutdown"};const exit = \\{"jsonrpc":"2.0","method":"exit"};
test "positions count in the encoding asked for" { // `é` is two bytes and one UTF-16 unit; `𝄞` is four bytes and two. const text = "ab\né𝄞x"; const at_x: struthio.Diagnostic.Pos = .{ .line = 2, .column = 3 }; try testing.expectEqual(Position{ .line = 1, .character = 6 }, position(text, at_x, .utf8)); try testing.expectEqual(Position{ .line = 1, .character = 3 }, position(text, at_x, .utf16)); try testing.expectEqual(Position{ .line = 1, .character = 2 }, position(text, at_x, .utf32)); try testing.expectEqual(Position{ .line = 0, .character = 1 }, position(text, .{ .line = 1, .column = 2 }, .utf16)); try testing.expectEqual(Position{ .line = 1, .character = 4 }, end(text, .utf16)); try testing.expectEqual(Position{ .line = 2, .character = 0 }, end("a\nb\n", .utf16));}
test "it initializes, shuts down and exits" { var s: Session = try .run(&.{ initialize, initialized, shutdown, exit }); defer s.deinit(); try testing.expectEqual(0, s.status); try testing.expectEqual(2, s.replies.len); try testing.expectEqualStrings( \\{"jsonrpc":"2.0","id":1,"result":{"capabilities":{"positionEncoding":"utf-16","textDocumentSync":{"openClose":true,"change":1},"documentFormattingProvider":true,"foldingRangeProvider":true,"selectionRangeProvider":true,"hoverProvider":true,"definitionProvider":true,"documentHighlightProvider":true,"referencesProvider":true,"completionProvider":{"triggerCharacters":["@"]},"renameProvider":{"prepareProvider":true},"signatureHelpProvider":{"triggerCharacters":["(",","]},"documentSymbolProvider":true,"codeActionProvider":{"codeActionKinds":["quickfix"]}},"serverInfo":{"name":"struthio","version":"0.1.0"}}} , try s.text(0)); try testing.expectEqualStrings( \\{"jsonrpc":"2.0","id":99,"result":null} , try s.text(1));}
test "it exits with 1 without a shutdown, and at the end of its input" { var s: Session = try .run(&.{ initialize, exit }); defer s.deinit(); try testing.expectEqual(1, s.status); var t: Session = try .run(&.{initialize}); defer t.deinit(); try testing.expectEqual(1, t.status);}
test "it prefers UTF-8 when the client offers it" { var s: Session = try .run(&.{ \\{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{"general":{"positionEncodings":["utf-16","utf-8"]}}}} }); defer s.deinit(); const caps = s.replies[0].object.get("result").?.object.get("capabilities").?; try testing.expectEqualStrings("utf-8", caps.object.get("positionEncoding").?.string);}
test "a request before initialize is refused, and an unknown one is not found" { var s: Session = try .run(&.{ \\{"jsonrpc":"2.0","id":1,"method":"shutdown"} , initialize, \\{"jsonrpc":"2.0","id":2,"method":"workspace/symbol","params":{}} }); defer s.deinit(); try testing.expectEqualStrings( \\{"jsonrpc":"2.0","id":1,"error":{"code":-32002,"message":"not initialized"}} , try s.text(0)); try testing.expectEqualStrings( \\{"jsonrpc":"2.0","id":2,"error":{"code":-32601,"message":"no such method"}} , try s.text(2));}
test "a document that does not parse gets a diagnostic, and loses it when fixed" { var s: Session = try .run(&.{ initialize, \\{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"file:///conf/a.struthio","languageId":"struthio","version":1,"text":"let x = 1;\n.{ .a = why }"}}} , \\{"jsonrpc":"2.0","method":"textDocument/didChange","params":{"textDocument":{"uri":"file:///conf/a.struthio","version":2},"contentChanges":[{"text":"let x = 1;\n.{ .a = x }"}]}} }); defer s.deinit(); try testing.expectEqual(3, s.replies.len); try testing.expectEqualStrings( \\{"jsonrpc":"2.0","method":"textDocument/publishDiagnostics","params":{"uri":"file:///conf/a.struthio","diagnostics":[{"range":{"start":{"line":1,"character":8},"end":{"line":1,"character":11}},"severity":1,"source":"struthio","message":"unknown name 'why'"},{"range":{"start":{"line":0,"character":4},"end":{"line":0,"character":5}},"severity":2,"source":"struthio","message":"'x' is never used","tags":[1]}]}} , try s.text(1)); try testing.expectEqualStrings( \\{"jsonrpc":"2.0","method":"textDocument/publishDiagnostics","params":{"uri":"file:///conf/a.struthio","diagnostics":[]}} , try s.text(2));}
test "a function the editor cannot know about is not an error" { var s: Session = try .run(&.{ initialize, \\{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"untitled:1","languageId":"struthio","version":1,"text":".{ .a = @env(\"HOME\"), .b = \"${@lower(@host())}\" }"}}} }); defer s.deinit(); try testing.expectEqualStrings( \\{"jsonrpc":"2.0","method":"textDocument/publishDiagnostics","params":{"uri":"untitled:1","diagnostics":[]}} , try s.text(1));}
test "an include is read from the editor, and a mistake in it is pointed at" { var s: Session = try .run(&.{ initialize, \\{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"file:///conf/base.struthio","languageId":"struthio","version":1,"text":".{ .port = @port() }"}}} , \\{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"file:///conf/main.struthio","languageId":"struthio","version":1,"text":"@include(\"base.struthio\") ++ .{ .tls = true }"}}} , \\{"jsonrpc":"2.0","method":"textDocument/didChange","params":{"textDocument":{"uri":"file:///conf/base.struthio","version":2},"contentChanges":[{"text":".{ .port = }"}]}} }); defer s.deinit(); // One for opening base; one each for both after opening main; and again // after changing base. try testing.expectEqual(6, s.replies.len); // base's own function, found only in base, is known in main too. try testing.expectEqualStrings( \\{"jsonrpc":"2.0","method":"textDocument/publishDiagnostics","params":{"uri":"file:///conf/main.struthio","diagnostics":[]}} , try s.text(3)); try testing.expectEqualStrings( \\{"jsonrpc":"2.0","method":"textDocument/publishDiagnostics","params":{"uri":"file:///conf/main.struthio","diagnostics":[{"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":0}},"severity":1,"source":"struthio","message":"in file:///conf/base.struthio:1:12: expected an expression, found '}'","relatedInformation":[{"location":{"uri":"file:///conf/base.struthio","range":{"start":{"line":0,"character":11},"end":{"line":0,"character":11}}},"message":"expected an expression, found '}'"}]}]}} , try s.text(5));}
test "formatting replaces the document with what struthio fmt makes of it" { var s: Session = try .run(&.{ initialize, \\{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"file:///a.struthio","languageId":"struthio","version":1,"text":".{.a=1,\n.b=2}"}}} , \\{"jsonrpc":"2.0","id":2,"method":"textDocument/formatting","params":{"textDocument":{"uri":"file:///a.struthio"},"options":{"tabSize":4,"insertSpaces":true}}} , \\{"jsonrpc":"2.0","id":3,"method":"textDocument/formatting","params":{"textDocument":{"uri":"file:///nowhere.struthio"},"options":{"tabSize":4,"insertSpaces":true}}} }); defer s.deinit(); try testing.expectEqualStrings( \\{"jsonrpc":"2.0","id":2,"result":[{"range":{"start":{"line":0,"character":0},"end":{"line":1,"character":5}},"newText":".{ .a = 1, .b = 2 }\n"}]} , try s.text(2)); try testing.expectEqualStrings( \\{"jsonrpc":"2.0","id":3,"result":null} , try s.text(3));}
test "a line index converts both ways, in each encoding" { // `é` is two bytes, one UTF-16 unit and one UTF-32 one; `𝄞` is four // bytes, two UTF-16 units and one UTF-32 one. const text = "ab\né𝄞x\n"; const x = std.mem.findScalar(u8, text, 'x').?; var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); for ([_]struct { Encoding, u32 }{ .{ .utf8, 6 }, .{ .utf16, 3 }, .{ .utf32, 2 } }) |case| { const index: LineIndex = try .init(arena.allocator(), text, case[0]); const at_x: Position = .{ .line = 1, .character = case[1] }; try testing.expectEqual(at_x, index.position(@intCast(x))); try testing.expectEqual(x, index.offset(at_x)); try testing.expectEqual(Position{ .line = 2, .character = 0 }, index.position(@intCast(text.len))); // Past the end of a line is its end, and past the last line the end // of the text. try testing.expectEqual(x + 1, index.offset(.{ .line = 1, .character = 99 })); try testing.expectEqual(text.len, index.offset(.{ .line = 9, .character = 0 })); } // In the middle of a character is at its start. const index: LineIndex = try .init(arena.allocator(), text, .utf16); try testing.expectEqual(5, index.offset(.{ .line = 1, .character = 2 }));}
test "folds are literals, calls, switches, multiline strings and comments" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const text = \\// One comment line \\// and another. \\.{ \\ .a = .{ .b = .{ \\ 1, \\ } }, \\ .motd = \\ \\one \\ \\two \\ , \\ .c = switch (ctx.n) { \\ else => 1, \\ }, \\ .d = @max(1, 2), // not over lines \\} ; const folds = try foldingRanges(arena.allocator(), text); const expected = [_]FoldingRange{ .{ .startLine = 0, .endLine = 1, .kind = "comment" }, .{ .startLine = 2, .endLine = 13 }, // The literal on line 3 inside it is not a fold of its own. .{ .startLine = 3, .endLine = 4 }, .{ .startLine = 7, .endLine = 8 }, .{ .startLine = 10, .endLine = 11 }, }; try testing.expectEqual(expected.len, folds.len); for (expected, folds) |e, f| { try testing.expectEqual(e.startLine, f.startLine); try testing.expectEqual(e.endLine, f.endLine); try testing.expectEqualStrings(e.kind orelse "", f.kind orelse ""); }
// Not parsing, only the comments fold. const broken = try foldingRanges(arena.allocator(), "// a\n// b\n.{\n.a = \n}"); try testing.expectEqual(1, broken.len); try testing.expectEqualStrings("comment", broken[0].kind.?);}
test "a selection grows from a name to the whole program" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const text = "let a = 1;\n.{ .b = a + 2 }"; const index: LineIndex = try .init(arena.allocator(), text, .utf16); // On the `a` of `a + 2`. const at = std.mem.findScalarLast(u8, text, 'a').?; const got = try selectionRanges(arena.allocator(), text, .utf16, &.{index.position(@intCast(at))}); var selected: std.ArrayList([]const u8) = .empty; var r: ?*const SelectionRange = &got[0]; while (r) |s| : (r = s.parent) { try selected.append(arena.allocator(), text[index.offset(s.range.start)..index.offset(s.range.end)]); } const expected = [_][]const u8{ "a", "a + 2", ".b = a + 2", ".{ .b = a + 2 }", text }; try testing.expectEqual(expected.len, selected.items.len); for (expected, selected.items) |e, s| try testing.expectEqualStrings(e, s);
// Not parsing, a selection is where it was. const broken = try selectionRanges(arena.allocator(), ".{ .a = }", .utf16, &.{.{ .line = 0, .character = 3 }}); try testing.expectEqual(Range{ .start = .{ .line = 0, .character = 3 }, .end = .{ .line = 0, .character = 3 } }, broken[0].range); try testing.expectEqual(null, broken[0].parent);}
test "folding and selection ranges are answered" { var s: Session = try .run(&.{ initialize, \\{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"file:///a.struthio","languageId":"struthio","version":1,"text":".{\n .a = 1,\n}"}}} , \\{"jsonrpc":"2.0","id":2,"method":"textDocument/foldingRange","params":{"textDocument":{"uri":"file:///a.struthio"}}} , \\{"jsonrpc":"2.0","id":3,"method":"textDocument/selectionRange","params":{"textDocument":{"uri":"file:///a.struthio"},"positions":[{"line":1,"character":9}]}} }); defer s.deinit(); try testing.expectEqualStrings( \\{"jsonrpc":"2.0","id":2,"result":[{"startLine":0,"endLine":1}]} , try s.text(2)); try testing.expectEqualStrings( \\{"jsonrpc":"2.0","id":3,"result":[{"range":{"start":{"line":1,"character":9},"end":{"line":1,"character":10}},"parent":{"range":{"start":{"line":1,"character":4},"end":{"line":1,"character":10}},"parent":{"range":{"start":{"line":0,"character":0},"end":{"line":2,"character":1}}}}}]} , try s.text(3));}
/// The hover at the first `needle` in `text`, `skip` bytes in.fn hoverAt(arena: Allocator, text: []const u8, needle: []const u8, skip: usize) !?Hover { const at = std.mem.find(u8, text, needle).? + skip; const index: LineIndex = try .init(arena, text, .utf16); return hover(arena, text, .utf16, index.position(@intCast(at)));}
test "hovering a name shows the let that binds it, and the comment above" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const a = arena.allocator(); const text = \\// Where the server listens. \\// Not 80, which needs root. \\let port = 8080; \\let host = ctx.hostname; \\.{ .listen = "${host}", .port = port } ; const expected = \\```struthio \\let port = 8080; \\``` \\ \\Where the server listens. \\Not 80, which needs root. ; // Where it is used, and where it is bound. const used = (try hoverAt(a, text, "= port", 3)).?; try testing.expectEqualStrings(expected, used.contents.value); try testing.expectEqual(Range{ .start = .{ .line = 4, .character = 32 }, .end = .{ .line = 4, .character = 36 } }, used.range); const bound = (try hoverAt(a, text, "port =", 0)).?; try testing.expectEqualStrings(expected, bound.contents.value);
// With no comment above it, only the let. const host = (try hoverAt(a, text, "host = ctx", 0)).?; try testing.expectEqualStrings("```struthio\nlet host = ctx.hostname;\n```", host.contents.value);
// Inside a string's `${ }` too; but not a field's own name. try testing.expectEqualStrings(host.contents.value, (try hoverAt(a, text, "${host}", 2)).?.contents.value); try testing.expectEqual(null, try hoverAt(a, text, "listen", 0));}
test "hovering a capture says what it holds, where it is in scope" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const a = arena.allocator(); const text = "if (ctx.cache) |c| switch (c.kind) { .redis => |r| r, else => c } else 0"; const c = (try hoverAt(a, text, "else => c", 8)).?; try testing.expectEqualStrings("```struthio\n|c|\n```\nThe value of `ctx.cache`, which is not `null` here.", c.contents.value); try testing.expectEqualStrings(c.contents.value, (try hoverAt(a, text, "|c|", 1)).?.contents.value); const r = (try hoverAt(a, text, "r, else", 0)).?; try testing.expectEqualStrings( "```struthio\n|r|\n```\nWhat `c.kind` matched: the payload of a tagged union matched by its tag, and otherwise the value itself.", r.contents.value, );}
test "a capture is not seen in an if's else" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const a = arena.allocator(); // `x` in the else is the let, not the capture -- which a program could // not write, since names cannot be rebound, but whose scope is so. try testing.expectEqual(null, try hoverAt(a, "if (ctx.a) |x| 1 else x", "else x", 5));}
test "hovering a function, ctx, inf or nan describes it" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const a = arena.allocator(); const text = ".{ @trim(ctx.name), @env(\"HOME\"), inf, nan }"; const trim = (try hoverAt(a, text, "@trim", 2)).?; try testing.expectEqualStrings( "```struthio\n@trim(s)\n```\n`s` without the spaces, tabs, carriage returns and line feeds at either end.", trim.contents.value, ); try testing.expectEqual(Range{ .start = .{ .line = 0, .character = 3 }, .end = .{ .line = 0, .character = 8 } }, trim.range); try testing.expect(std.mem.startsWith(u8, (try hoverAt(a, text, "@env", 1)).?.contents.value, "```struthio\n@env(...)\n```\nNot a built-in")); try testing.expect(std.mem.startsWith(u8, (try hoverAt(a, text, "ctx", 0)).?.contents.value, "```struthio\nctx\n```")); try testing.expect(std.mem.startsWith(u8, (try hoverAt(a, text, "inf", 0)).?.contents.value, "```struthio\ninf\n```")); try testing.expect(std.mem.startsWith(u8, (try hoverAt(a, text, "nan", 0)).?.contents.value, "```struthio\nnan\n```")); // Not a name at all. try testing.expectEqual(null, try hoverAt(a, "1 + 2", "+", 0)); // Not parsing. try testing.expectEqual(null, try hoverAt(a, "let x = ;", "x", 0));}
test "every built-in with a description is a built-in" { for (builtin_docs) |doc| { // Called with no host functions and its fewest arguments, it parses. var source: std.ArrayList(u8) = .empty; defer source.deinit(testing.allocator); try source.print(testing.allocator, "@{s}(", .{doc.name}); const args: usize = if (std.mem.eql(u8, doc.name, "replace")) 3 else if (std.mem.find(u8, doc.signature, ",") != null) 2 else 1; // `/x`, which an `@include` can resolve with no filename of its own. for (0..args) |i| try source.appendSlice(testing.allocator, if (i == 0) "\"/x\"" else ", \"x\""); try source.append(testing.allocator, ')'); var diag: struthio.Diagnostic = .{}; var program = struthio.Program.parse(testing.allocator, source.items, .{ .include = .{ .read = struct { fn read(_: ?*anyopaque, _: Allocator, _: []const u8) anyerror![]const u8 { return "null"; } }.read }, }, &diag) catch |err| { std.debug.print("{s}: {f}\n", .{ source.items, &diag }); return err; }; program.deinit(); }}
test "hover is answered" { var s: Session = try .run(&.{ initialize, \\{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"file:///a.struthio","languageId":"struthio","version":1,"text":"@lower(ctx.name)"}}} , \\{"jsonrpc":"2.0","id":2,"method":"textDocument/hover","params":{"textDocument":{"uri":"file:///a.struthio"},"position":{"line":0,"character":1}}} , \\{"jsonrpc":"2.0","id":3,"method":"textDocument/hover","params":{"textDocument":{"uri":"file:///a.struthio"},"position":{"line":0,"character":6}}} }); defer s.deinit(); try testing.expectEqualStrings( \\{"jsonrpc":"2.0","id":2,"result":{"contents":{"kind":"markdown","value":"```struthio\n@lower(s)\n```\n`s` with its ASCII letters in lower case."},"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":6}}}} , try s.text(2)); try testing.expectEqualStrings( \\{"jsonrpc":"2.0","id":3,"result":null} , try s.text(3));}
/// The position of the first `needle` in `text`, `skip` bytes in.fn posAt(arena: Allocator, text: []const u8, needle: []const u8, skip: usize) !Position { const at = std.mem.find(u8, text, needle).? + skip; const index: LineIndex = try .init(arena, text, .utf16); return index.position(@intCast(at));}
/// Each range in `ranges` as the text it covers, one per line.fn covered(arena: Allocator, text: []const u8, ranges: []const Range) ![]const u8 { const index: LineIndex = try .init(arena, text, .utf16); var out: Io.Writer.Allocating = .init(arena); for (ranges) |r| { const start = index.offset(r.start); try out.writer.print("{d}:{d} {s}\n", .{ r.start.line, r.start.character, text[start..index.offset(r.end)] }); } return out.written();}
test "a name's definition is where it is bound" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const a = arena.allocator(); const text = \\let port = 8080; \\if (ctx.tls) |tls| .{ .port = port, .cert = "${tls.cert}" } else .{ .port = port } ; const port = (try definition(a, text, .utf16, try posAt(a, text, "= port", 3))).?; try testing.expectEqualStrings("0:4 port\n", try covered(a, text, &.{port.here})); const tls = (try definition(a, text, .utf16, try posAt(a, text, "tls.cert", 1))).?; try testing.expectEqualStrings("1:14 tls\n", try covered(a, text, &.{tls.here})); // Where it is bound is its own definition. try testing.expectEqualStrings("0:4 port\n", try covered(a, text, &.{(try definition(a, text, .utf16, .{ .line = 0, .character = 5 })).?.here})); // Nothing for a function, `ctx`, a field's name, or a document that does // not parse. try testing.expectEqual(null, try definition(a, text, .utf16, try posAt(a, text, "ctx", 0))); try testing.expectEqual(null, try definition(a, text, .utf16, try posAt(a, text, ".cert", 1))); try testing.expectEqual(null, try definition(a, "let x = ;", .utf16, .{ .line = 0, .character = 4 }));}
test "an include's definition is its path" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const a = arena.allocator(); const text = "@include(\"base.struthio\") ++ @env(\"x\")"; // From the function's name, and from the path. try testing.expectEqualStrings("\"base.struthio\"", (try definition(a, text, .utf16, .{ .line = 0, .character = 2 })).?.include); try testing.expectEqualStrings("\"base.struthio\"", (try definition(a, text, .utf16, .{ .line = 0, .character = 12 })).?.include); try testing.expectEqual(null, try definition(a, text, .utf16, try posAt(a, text, "\"x\"", 1))); try testing.expectEqual(null, try definition(a, text, .utf16, try posAt(a, text, "@env", 1)));}
test "highlights are every place a name is written" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const a = arena.allocator(); const text = \\let x = 1; \\.{ x, if (ctx.a) |x2| x2 + x else @f(x), switch (x) { else => |x3| x3 }, "${x}", @f(1) } ; var got: Io.Writer.Allocating = .init(a); const index: LineIndex = try .init(a, text, .utf16); for ((try highlights(a, text, .utf16, try posAt(a, text, "x, if", 0))).?) |h| { try got.writer.print("{d}:{s} ", .{ h.kind, text[index.offset(h.range.start)..index.offset(h.range.end)] }); try testing.expectEqual(index.offset(h.range.end) - index.offset(h.range.start), 1); } try testing.expectEqualStrings("3:x 2:x 2:x 2:x 2:x 2:x ", got.written());
const capture = (try highlights(a, text, .utf16, try posAt(a, text, "x2 +", 0))).?; try testing.expectEqualStrings("1:18 x2\n1:22 x2\n", try covered(a, text, &.{ capture[0].range, capture[1].range })); try testing.expectEqual(2, capture.len);
const calls = (try highlights(a, text, .utf16, try posAt(a, text, "@f(x)", 1))).?; try testing.expectEqual(2, calls.len); try testing.expectEqual(1, calls[0].kind);
try testing.expectEqual(null, try highlights(a, text, .utf16, try posAt(a, text, "1;", 0)));}
test "references leave out the binding when asked to" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const a = arena.allocator(); const text = "let @\"a b\" = 1; .{ @\"a b\", @\"a b\" + 1 }"; const all = (try references(a, text, .utf16, .{ .line = 0, .character = 5 }, true)).?; try testing.expectEqualStrings("0:4 @\"a b\"\n0:19 @\"a b\"\n0:27 @\"a b\"\n", try covered(a, text, all)); const uses = (try references(a, text, .utf16, .{ .line = 0, .character = 5 }, false)).?; try testing.expectEqual(2, uses.len);}
test "definition, highlights and references are answered" { var s: Session = try .run(&.{ initialize, \\{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"file:///conf/base.struthio","languageId":"struthio","version":1,"text":".{}"}}} , \\{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"file:///conf/a.struthio","languageId":"struthio","version":1,"text":"let x = 1;\n@include(\"base.struthio\") ++ @include(\"./sub/../other.struthio\") ++ .{ .a = x }"}}} , \\{"jsonrpc":"2.0","id":2,"method":"textDocument/definition","params":{"textDocument":{"uri":"file:///conf/a.struthio"},"position":{"line":1,"character":76}}} , \\{"jsonrpc":"2.0","id":3,"method":"textDocument/definition","params":{"textDocument":{"uri":"file:///conf/a.struthio"},"position":{"line":1,"character":12}}} , \\{"jsonrpc":"2.0","id":4,"method":"textDocument/definition","params":{"textDocument":{"uri":"file:///conf/a.struthio"},"position":{"line":1,"character":45}}} , \\{"jsonrpc":"2.0","id":5,"method":"textDocument/documentHighlight","params":{"textDocument":{"uri":"file:///conf/a.struthio"},"position":{"line":0,"character":4}}} , \\{"jsonrpc":"2.0","id":6,"method":"textDocument/references","params":{"textDocument":{"uri":"file:///conf/a.struthio"},"position":{"line":0,"character":4},"context":{"includeDeclaration":false}}} }); defer s.deinit(); // After the reply to initialize, three diagnostics: one for the first // document opened, and one for each when the second is. try testing.expectEqualStrings( \\{"jsonrpc":"2.0","id":2,"result":{"uri":"file:///conf/a.struthio","range":{"start":{"line":0,"character":4},"end":{"line":0,"character":5}}}} , try s.text(4)); // Open, so named as the client named it. try testing.expectEqualStrings( \\{"jsonrpc":"2.0","id":3,"result":{"uri":"file:///conf/base.struthio","range":{"start":{"line":0,"character":0},"end":{"line":0,"character":0}}}} , try s.text(5)); // Not open, so named as the parser names it. try testing.expectEqualStrings( \\{"jsonrpc":"2.0","id":4,"result":{"uri":"file:///conf/other.struthio","range":{"start":{"line":0,"character":0},"end":{"line":0,"character":0}}}} , try s.text(6)); try testing.expectEqualStrings( \\{"jsonrpc":"2.0","id":5,"result":[{"range":{"start":{"line":0,"character":4},"end":{"line":0,"character":5}},"kind":3},{"range":{"start":{"line":1,"character":76},"end":{"line":1,"character":77}},"kind":2}]} , try s.text(7)); try testing.expectEqualStrings( \\{"jsonrpc":"2.0","id":6,"result":[{"uri":"file:///conf/a.struthio","range":{"start":{"line":1,"character":76},"end":{"line":1,"character":77}}}]} , try s.text(8));}
/// The labels completion offers at the `#` in `marked`, which is removed.fn completeAt(arena: Allocator, marked: []const u8, functions: []const []const u8) ![]const u8 { const bar = std.mem.findScalar(u8, marked, '#').?; const text = try std.mem.concat(arena, u8, &.{ marked[0..bar], marked[bar + 1 ..] }); const index: LineIndex = try .init(arena, text, .utf16); var out: Io.Writer.Allocating = .init(arena); for (try completions(arena, text, .utf16, index.position(@intCast(bar)), functions)) |item| { try out.writer.print("{s} ", .{item.label}); } return out.written();}
const always = "ctx true false null inf nan if switch else orelse and or ";
test "completion offers the names in scope" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const a = arena.allocator(); // A let after its `;`, not in its own value. try testing.expectEqualStrings("a " ++ always, try completeAt(a, "let a = 1;\nlet b = #", &.{})); try testing.expectEqualStrings("a b " ++ always, try completeAt(a, "let a = 1;\nlet b = 2;\n.{ .x = b#, }", &.{})); // A capture in its body, and not after it ends. try testing.expectEqualStrings("x " ++ always, try completeAt(a, "if (ctx.a) |x| .{ #", &.{})); try testing.expectEqualStrings(always, try completeAt(a, "if (ctx.a) |x| 1 else #", &.{})); try testing.expectEqualStrings("x " ++ always, try completeAt(a, "if (ctx.a) |x| if (ctx.b) |y| 1 else #", &.{})); try testing.expectEqualStrings("v " ++ always, try completeAt(a, "switch (ctx.a) { .b => |v| #", &.{})); try testing.expectEqualStrings(always, try completeAt(a, "switch (ctx.a) { .b => |v| v, .c => #", &.{})); try testing.expectEqualStrings(always, try completeAt(a, ".{ if (ctx.a) |x| x, #", &.{})); // In an interpolation, even one not yet closed, but not in a string's // text or a comment, or after a `.`. try testing.expectEqualStrings("a " ++ always, try completeAt(a, "let a = 1;\n\"${a + #", &.{})); try testing.expectEqualStrings("a " ++ always, try completeAt(a, "let a = 1;\n\\\\one ${#}\n", &.{})); try testing.expectEqualStrings("", try completeAt(a, "let a = 1;\n\"a#", &.{})); try testing.expectEqualStrings("", try completeAt(a, "let a = 1;\n\"${a}#\"", &.{})); try testing.expectEqualStrings("", try completeAt(a, "let a = 1; // a#\n", &.{})); try testing.expectEqualStrings("", try completeAt(a, "ctx.#", &.{})); try testing.expectEqualStrings("", try completeAt(a, "1#", &.{})); // A broken string ends at its line. try testing.expectEqualStrings("a " ++ always, try completeAt(a, "let a = 1;\n.{ \"x,\n a, #", &.{}));}
test "completion after @ offers the functions" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const a = arena.allocator(); const text = "@lo"; const items = try completions(a, text, .utf16, .{ .line = 0, .character = 3 }, &.{ "env", "lower", "env" }); try testing.expectEqual(builtin_docs.len + 1, items.len); try testing.expectEqualStrings("@lower", items[0].label); try testing.expectEqualStrings("@lower(s)", items[0].detail.?); try testing.expectEqual(Range{ .start = .{ .line = 0, .character = 0 }, .end = .{ .line = 0, .character = 3 } }, items[0].textEdit.?.range); try testing.expectEqualStrings("@env", items[items.len - 1].label); // Not in a string's text. try testing.expectEqual(0, (try completions(a, "\"me@", .utf16, .{ .line = 0, .character = 4 }, &.{})).len);}
test "completion is answered, with the functions other documents call" { var s: Session = try .run(&.{ initialize, \\{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"file:///a.struthio","languageId":"struthio","version":1,"text":"@secret(\"a\")"}}} , \\{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"file:///b.struthio","languageId":"struthio","version":1,"text":"@"}}} , \\{"jsonrpc":"2.0","id":2,"method":"textDocument/completion","params":{"textDocument":{"uri":"file:///b.struthio"},"position":{"line":0,"character":1}}} }); defer s.deinit(); const reply = s.replies[4].object.get("result").?.array.items; try testing.expectEqual(builtin_docs.len + 1, reply.len); try testing.expectEqualStrings( \\{"label":"@secret","kind":3,"detail":"host function","filterText":"@secret","textEdit":{"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":1}},"newText":"@secret"}} , try json.Stringify.valueAlloc(s.arena.allocator(), reply[reply.len - 1], .{}));}
test "every request survives every prefix of a program being typed" { const program = \\// The base. \\let base = @include("base.struthio"); \\let name = @"the name"; \\ \\if (ctx.tls) |tls| .{ \\ .listen = "${ctx.host}:${@string(base.port + 1)}", \\ .cert = tls.cert orelse "/etc/${name}.pem", \\ .mode = switch (ctx.mode) { \\ .dev, .test => |m| @lower("${m}"), \\ 1...5 => 'x', \\ else => \\${name} \\ \\two \\ , \\ }, \\ .list = .{ inf, -nan, null, base.list[1], true }, \\} else base ; var gpa_arena: std.heap.ArenaAllocator = .init(testing.allocator); defer gpa_arena.deinit(); for (0..program.len + 1) |len| { _ = gpa_arena.reset(.retain_capacity); const a = gpa_arena.allocator(); const text = program[0..len]; const index: LineIndex = try .init(a, text, .utf16); const at = index.position(@intCast(len)); _ = try completions(a, text, .utf16, at, &.{"env"}); _ = try signatureHelp(a, text, .utf16, at); _ = try documentSymbols(a, text, .utf16); _ = try unusedWarnings(a, text, .utf16); _ = try codeActions(a, text, .utf16, .{ .start = .{ .line = 0, .character = 0 }, .end = at }, &.{ .{ .line = 0, .character = 0 }, at }); for ([_]u32{ 0, @intCast(len / 2), @intCast(len -| 1) }) |off| { const p = index.position(off); _ = try hover(a, text, .utf16, p); _ = try definition(a, text, .utf16, p); _ = try highlights(a, text, .utf16, p); _ = try references(a, text, .utf16, p, false); _ = try prepareRename(a, text, .utf16, p); _ = rename(a, text, .utf16, p, "renamed") catch |err| switch (err) { error.OutOfMemory => return err, else => {}, }; } } // And at every offset of the whole program, which parses. _ = gpa_arena.reset(.retain_capacity); const a = gpa_arena.allocator(); try testing.expect(try Outlined.init(a, program, .utf16) != null); const index: LineIndex = try .init(a, program, .utf16); for (0..program.len + 1) |off| { const p = index.position(@intCast(off)); _ = try completions(a, program, .utf16, p, &.{}); _ = try hover(a, program, .utf16, p); _ = try definition(a, program, .utf16, p); _ = try highlights(a, program, .utf16, p); }}
/// `text` with `edits` made, which must not overlap.fn applyEdits(arena: Allocator, text: []const u8, edits: []const TextEdit) ![]const u8 { const index: LineIndex = try .init(arena, text, .utf16); const sorted = try arena.dupe(TextEdit, edits); std.mem.sortUnstable(TextEdit, sorted, index, struct { fn lessThan(i: LineIndex, a: TextEdit, b: TextEdit) bool { return i.offset(a.range.start) < i.offset(b.range.start); } }.lessThan); var out: std.ArrayList(u8) = .empty; var at: u32 = 0; for (sorted) |e| { try out.appendSlice(arena, text[at..index.offset(e.range.start)]); try out.appendSlice(arena, e.newText); at = index.offset(e.range.end); } try out.appendSlice(arena, text[at..]); return out.items;}
test "renaming a name renames every place it is written" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const a = arena.allocator(); const text = "let port = 1;\nif (ctx.a) |x| .{ port, \"${port}\", x } else port"; const at = try posAt(a, text, "port, ", 0); try testing.expectEqualStrings( "let p = 1;\nif (ctx.a) |x| .{ p, \"${p}\", x } else p", try applyEdits(a, text, try rename(a, text, .utf16, at, "p")), ); // Quoted when it has to be, and unquoted when it need not be. try testing.expectEqualStrings( "let @\"the \\\"port\\\"\" = 1;\nif (ctx.a) |x| .{ @\"the \\\"port\\\"\", \"${@\"the \\\"port\\\"\"}\", x } else @\"the \\\"port\\\"\"", try applyEdits(a, text, try rename(a, text, .utf16, at, "the \"port\"")), ); try testing.expectEqualStrings( "let @\"if\" = 1;\nif (ctx.a) |x| .{ @\"if\", \"${@\"if\"}\", x } else @\"if\"", try applyEdits(a, text, try rename(a, text, .utf16, at, "if")), ); try testing.expectEqualStrings( "let port = 1;\nif (ctx.a) |y| .{ port, \"${port}\", y } else port", try applyEdits(a, text, try rename(a, text, .utf16, try posAt(a, text, "x }", 0), "@\"y\"")), ); // The range is the name where it is asked about. try testing.expectEqual(Range{ .start = .{ .line = 1, .character = 35 }, .end = .{ .line = 1, .character = 36 } }, (try prepareRename(a, text, .utf16, try posAt(a, text, "x }", 0))).?);}
test "a rename that would change what a name means is refused" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const a = arena.allocator(); const text = "let a = 1;\nlet b = 2;\nif (ctx.c) |x| a + x else switch (b) { else => |y| y }"; const pos = try posAt(a, text, "a + x", 0); // Already bound where `a` is seen, before it or after it. try testing.expectError(error.Conflict, rename(a, text, .utf16, pos, "b")); try testing.expectError(error.Conflict, rename(a, text, .utf16, try posAt(a, text, "b = 2", 0), "a")); // Bound where `a` is seen. try testing.expectError(error.Conflict, rename(a, text, .utf16, pos, "x")); // Seen where `x` is bound. try testing.expectError(error.Conflict, rename(a, text, .utf16, try posAt(a, text, "x|", 0), "a")); try testing.expectError(error.Reserved, rename(a, text, .utf16, pos, "ctx")); try testing.expectError(error.BadName, rename(a, text, .utf16, pos, "a\nb")); try testing.expectError(error.BadName, rename(a, text, .utf16, pos, "")); try testing.expectError(error.NotRenameable, rename(a, text, .utf16, try posAt(a, text, "ctx", 0), "z")); try testing.expectEqual(null, try prepareRename(a, text, .utf16, try posAt(a, text, "ctx", 0))); // Captures in separate branches may share a name. _ = try rename(a, text, .utf16, try posAt(a, text, "y|", 0), "x"); // And renaming a name to itself changes nothing. _ = try rename(a, text, .utf16, pos, "a");}
test "signature help follows the arguments of a built-in" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const a = arena.allocator(); const Case = struct { []const u8, ?[]const u8, u32 }; for ([_]Case{ .{ "@replace(#", "@replace(s, from, to)", 0 }, .{ "@replace(ctx.s, .{ 1, 2 }, #", "@replace(s, from, to)", 2 }, .{ "@replace(@lower(#", "@lower(s)", 0 }, .{ "@replace(@lower(x), #", "@replace(s, from, to)", 1 }, .{ "@min(#", "@min(list)", 0 }, .{ "@min(1, 2, 3, #", "@min(a, b, ...)", 2 }, .{ "@env(#", null, 0 }, .{ "@lower(\"${#", null, 0 }, .{ "@lower(\"a#", null, 0 }, .{ "@lower(x)#", null, 0 }, }) |case| { const bar = std.mem.findScalar(u8, case[0], '#').?; const text = case[0][0..bar]; const help = try signatureHelp(a, text, .utf16, .{ .line = 0, .character = @intCast(bar) }); if (case[1]) |label| { const h = help orelse { std.debug.print("no help in {s}\n", .{case[0]}); return error.TestExpectedEqual; }; try testing.expectEqualStrings(label, h.signatures[h.activeSignature].label); try testing.expectEqual(case[2], h.activeParameter); } else try testing.expectEqual(null, help); } const h = (try signatureHelp(a, "@replace(", .utf16, .{ .line = 0, .character = 9 })).?; const label = h.signatures[0].label; var names: std.ArrayList(u8) = .empty; for (h.signatures[0].parameters) |p| try names.print(a, "{s};", .{label[p.label[0]..p.label[1]]}); try testing.expectEqualStrings("s;from;to;", names.items);}
test "document symbols are the lets and fields, nested" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const a = arena.allocator(); const text = \\let base = .{ .port = 1, .tls = .{ .on = true } }; \\.{ .server = base, .client = .{ .retries = 3 }, .list = .{ 1, 2 } } ; var out: Io.Writer.Allocating = .init(a); const Print = struct { fn print(w: *Io.Writer, syms: []const DocumentSymbol) !void { for (syms) |sym| { try w.print("{s}:{d}", .{ sym.name, sym.kind }); if (sym.children.len > 0) { try w.writeAll("("); try print(w, sym.children); try w.writeAll(")"); } try w.writeAll(" "); } } }; try Print.print(&out.writer, (try documentSymbols(a, text, .utf16)).?); try testing.expectEqualStrings("base:13(port:8 tls:8(on:8 ) ) server:8 client:8(retries:8 ) list:8 ", out.written()); try testing.expectEqual(null, try documentSymbols(a, "let", .utf16));}
test "a let nothing uses is a warning, with a fix that removes it" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const a = arena.allocator(); const text = "let a = 1;\n let b = a; // b\nlet c = 2; let d = 3;\nd"; const warnings = try unusedWarnings(a, text, .utf16); try testing.expectEqual(2, warnings.len); try testing.expectEqualStrings("'b' is never used", warnings[0].message); try testing.expectEqualStrings("'c' is never used", warnings[1].message);
const everything: Range = .{ .start = .{ .line = 0, .character = 0 }, .end = end(text, .utf16) }; const actions = try codeActions(a, text, .utf16, everything, &.{}); try testing.expectEqual(2, actions.len); try testing.expectEqualStrings("Remove the unused 'b'", actions[0].title); // A let alone on its line goes with its line; one sharing it does not. try testing.expectEqualStrings("let a = 1;\n let b = a; // b\nlet d = 3;\nd", try applyEdits(a, text, actions[1].edits)); try testing.expectEqualStrings("let a = 1;\n // b\nlet c = 2; let d = 3;\nd", try applyEdits(a, text, actions[0].edits)); // Only those in the range asked about. try testing.expectEqual(0, (try codeActions(a, text, .utf16, .{ .start = .{ .line = 3, .character = 0 }, .end = .{ .line = 3, .character = 1 } }, &.{})).len);}
test "an unknown name gets a fix for each name in scope spelled almost the same" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const a = arena.allocator(); const text = "let host = 1;\nlet hots = 2;\nlet port = 3;\n.{ hots, hsot, port, ctz }"; const typo = try posAt(a, text, "hsot", 0); const range: Range = .{ .start = typo, .end = typo }; const actions = try codeActions(a, text, .utf16, range, &.{typo}); var titles: std.ArrayList(u8) = .empty; for (actions) |x| try titles.print(a, "{s}; ", .{x.title}); // `hots` is two edits away, too many for a name of four letters. try testing.expectEqualStrings("Change 'hsot' to 'host'; ", titles.items); try testing.expectEqualStrings("let host = 1;\nlet hots = 2;\nlet port = 3;\n.{ hots, host, port, ctz }", try applyEdits(a, text, actions[0].edits)); const ctz = try posAt(a, text, "ctz", 0); const fix = try codeActions(a, text, .utf16, .{ .start = ctz, .end = ctz }, &.{ctz}); try testing.expectEqualStrings("Change 'ctz' to 'ctx'", fix[0].title);}
test "edit distance" { try testing.expectEqual(0, distance("abc", "abc", 2)); try testing.expectEqual(1, distance("abc", "acb", 2)); try testing.expectEqual(1, distance("abc", "abcd", 2)); try testing.expectEqual(2, distance("kitten", "sitting", 2) - 1); try testing.expectEqual(3, distance("a", "abcdef", 2));}
test "rename, signature help, symbols and code actions are answered" { var s: Session = try .run(&.{ initialize, \\{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"file:///a.struthio","languageId":"struthio","version":1,"text":"let a = 1;\n.{ .x = a, .y = @lower(b) }"}}} , \\{"jsonrpc":"2.0","id":2,"method":"textDocument/prepareRename","params":{"textDocument":{"uri":"file:///a.struthio"},"position":{"line":0,"character":4}}} , \\{"jsonrpc":"2.0","id":3,"method":"textDocument/rename","params":{"textDocument":{"uri":"file:///a.struthio"},"position":{"line":0,"character":4},"newName":"b"}} , \\{"jsonrpc":"2.0","id":4,"method":"textDocument/rename","params":{"textDocument":{"uri":"file:///a.struthio"},"position":{"line":0,"character":4},"newName":"ctx"}} , \\{"jsonrpc":"2.0","id":5,"method":"textDocument/signatureHelp","params":{"textDocument":{"uri":"file:///a.struthio"},"position":{"line":1,"character":23}}} , \\{"jsonrpc":"2.0","id":6,"method":"textDocument/documentSymbol","params":{"textDocument":{"uri":"file:///a.struthio"}}} , \\{"jsonrpc":"2.0","id":7,"method":"textDocument/codeAction","params":{"textDocument":{"uri":"file:///a.struthio"},"range":{"start":{"line":1,"character":23},"end":{"line":1,"character":23}},"context":{"diagnostics":[{"range":{"start":{"line":1,"character":23},"end":{"line":1,"character":24}},"message":"unknown name 'b'"}]}}} }); defer s.deinit(); try testing.expectEqualStrings( \\{"jsonrpc":"2.0","id":2,"result":{"start":{"line":0,"character":4},"end":{"line":0,"character":5}}} , try s.text(2)); // `b` is not bound, but is written where `a` would be seen. try testing.expectEqualStrings( \\{"jsonrpc":"2.0","id":3,"error":{"code":-32803,"message":"that name is already used where this one is seen"}} , try s.text(3)); try testing.expectEqualStrings( \\{"jsonrpc":"2.0","id":4,"error":{"code":-32803,"message":"ctx, inf and nan are reserved"}} , try s.text(4)); try testing.expectEqualStrings( \\{"jsonrpc":"2.0","id":5,"result":{"signatures":[{"label":"@lower(s)","documentation":{"kind":"markdown","value":"`s` with its ASCII letters in lower case."},"parameters":[{"label":[7,8]}]}],"activeSignature":0,"activeParameter":0}} , try s.text(5)); try testing.expectEqualStrings( \\{"jsonrpc":"2.0","id":6,"result":[{"name":"a","kind":13,"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":10}},"selectionRange":{"start":{"line":0,"character":4},"end":{"line":0,"character":5}},"children":[]},{"name":"x","kind":8,"range":{"start":{"line":1,"character":3},"end":{"line":1,"character":9}},"selectionRange":{"start":{"line":1,"character":4},"end":{"line":1,"character":5}},"children":[]},{"name":"y","kind":8,"range":{"start":{"line":1,"character":11},"end":{"line":1,"character":25}},"selectionRange":{"start":{"line":1,"character":12},"end":{"line":1,"character":13}},"children":[]}]} , try s.text(6)); try testing.expectEqualStrings( \\{"jsonrpc":"2.0","id":7,"result":[{"title":"Change 'b' to 'a'","kind":"quickfix","isPreferred":false,"edit":{"changes":{"file:///a.struthio":[{"range":{"start":{"line":1,"character":23},"end":{"line":1,"character":24}},"newText":"a"}]}}}]} , try s.text(7));}
test "an empty answer is a null result, not a missing one" { var s: Session = try .run(&.{ initialize, \\{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"file:///a.struthio","languageId":"struthio","version":1,"text":"1 + 2"}}} , \\{"jsonrpc":"2.0","id":2,"method":"textDocument/signatureHelp","params":{"textDocument":{"uri":"file:///a.struthio"},"position":{"line":0,"character":1}}} , \\{"jsonrpc":"2.0","id":3,"method":"textDocument/prepareRename","params":{"textDocument":{"uri":"file:///a.struthio"},"position":{"line":0,"character":0}}} }); defer s.deinit(); try testing.expectEqualStrings( \\{"jsonrpc":"2.0","id":2,"result":null} , try s.text(2)); try testing.expectEqualStrings( \\{"jsonrpc":"2.0","id":3,"result":null} , try s.text(3));}