Language server
lsp/ is a Language Server Protocol implementation for d6 — a
standalone binary (build/d6-lsp, built from lsp/*.c plus the
same compiler/VM sources every other d6 binary shares) that speaks
LSP's standard Content-Length-framed JSON-RPC over stdio, the transport
every mainstream editor already knows how to launch a custom language
server with.
Incoming message bodies are limited to 64 MiB. A frame must have exactly one
Content-Length header containing an unsigned decimal byte count; malformed,
duplicate, and oversized lengths are rejected. The DAP adapter and VM debug
control channel use the same limit and length validation.
lsp/ — the JSON parser, the RPC framing, the protocol handling — is written
from scratch in C, with zero external dependencies beyond what the rest of this
repository already uses.
Positions
The compiler and lexer count a column in bytes within the line. The protocol counts a position's character in UTF-16 code units (the default encoding when a client negotiates none), and the two agree only on ASCII: an é is two bytes and one unit, an emoji four bytes and two. lsp/position.h converts at the edges. A cursor position from the editor becomes a byte column before anything looks at the source, and every range sent back (locations, symbols, references, rename edits, diagnostics, formatting edits) is converted using the text of the file the range is in, which for a definition, reference or dependency diagnostic in a required file is that file's text, not the open document's.
What it does today
Diagnostics, hover/go-to-definition/document-symbols over a narrow, declaration-only symbol table, plus completion and workspace-wide symbol search over a real (if scoped) lexical symbol table:
initialize— advertisestextDocumentSync: Full(1),hoverProvider: true,definitionProvider: true,documentSymbolProvider: true,completionProvider: {}(notriggerCharacters— see completion below for why none are needed),workspaceSymbolProvider: true,referencesProvider: true,renameProvider: true,documentFormattingProvider: true, anddocumentRangeFormattingProvider: true. Also readsworkspaceFolders[0]/rootUrifrom the request's own params (the one place this server reads anything frominitialize's params at all) to know what directoryworkspace/symbolshould search.textDocument/didOpen/didChange/didClose— each recompiles the document's current full text (full sync only; there's no incremental edit application) and publishes atextDocument/publishDiagnosticsnotification. d6's compiler stops at its first error, so there is never more than one diagnostic per publish — an empty array means the document currently compiles cleanly. If the one error is actually inside arequired file rather than the open document itself, a secondpublishDiagnosticsnotification also goes out, against that file's own uri (resolved the samediamond_resolve_diagnostic_locationway go-to-definition resolves a cross-fileLocation— see below) — this document's own publish still correctly reports itself clean, since its own text has no error. Only ever triggered by this document's own didOpen/didChange, not by editing the dependency directly; see below.textDocument/hover(lsp/hover.c),textDocument/definition(lsp/definition.c), andtextDocument/documentSymbol(lsp/document_symbol.c) all resolve the same declaration kinds, deliberately not a real general-purpose symbol table: a top-level function name (def foo, unambiguous since a bare call always resolves to exactly one top-level function by that name at compile time — no overloading, no scoping to worry about), a class name (always names exactly one class, single inheritance), a module name, or an interface name. The compiler records each one's declaration-site position (1-based line/column of its own name token, plus the matching byte offset in the compiled buffer —declaration_line/declaration_column/declaration_startonDiamondFunction/DiamondClass/DiamondModule/DiamondInterface,src/vm.h) purely for these three handlers to read; nothing else in the VM uses them.- Hover shows the reconstructed signature (parameter types, return
type, which parameters are optional),
class Name/class Name < Superclass,module Name, orinterface Name, reusingdisassemble.c's own type-set formatting (diamond_print_type_set). Hovering a function or method's declaration name selects the signature at that exact source position, including instance, singleton, module, nested, setter, and reopened-class definitions. Names shared by other classes or top-level functions do not override the declaration under the cursor. Go-to-definition uses the same position matching at function and method declarations, returning that declaration's name range in its own document. Setter ranges include the trailing=, and imported declarations use the current open buffer's source map. At explicit receiver calls such aspet.shared(), hover and definition resolve the method before any same-named top-level function or local. Unknown receivers and missing methods return no result rather than the unrelated global function. Bare function calls keep top-level lookup. Lexical locals with compiler-recorded structural facts are also hoverable; their position-sensitive type set is rendered from the owning function's table, including Callable, collection, primitive, and union graphs. - Go-to-definition returns a
Location. A match can legitimately live in a different file (something pulled in viarequire) — resolved to the right file and line throughdiamond_resolve_diagnostic_ location's segment table (src/compiler.h), the same machinery a compile error's own position already goes through, since a declaration's raw in-buffer line is only ever correct for the first thing after the most recent#line 1reset (diamond_load_programinserts one before every contiguous chunk it copies into the bundle, not just before required-file content) and needs the same segment-relative remap anywhere else. - Document symbols lists every top-level function, class, module, and
interface declared in
that document itself — not lib/core.d6's prelude, and not anything
pulled in through
require(each of those has its own outline, a didOpen away).range/selectionRangeare identical for each entry (just the name token — nothing tracks a declaration's full extent). - Class member completion includes the intrinsic
newconstructor, except for sealed classes. Go-to-definition onClassName.new(...)selects the instanceinitializemethod, including an inherited or imported one. Constructors without a source initializer (including generated struct constructors) select the class declaration. Nested class targets select the identifier written at that declaration, not the qualified lookup name. - All three also fall back to
lsp/receiver.cfor a method name reached throughreceiver.method(...), for the receiver forms resolvable without real type inference: a literal class name (Author.find),selfinside an instance method or a class-owneddef self.x(wall 2'sDIAMOND_VALUE_CLASS,docs/internal/design.md), a local variable known at the cursor position to holdClassName.new(...)—DiamondScopeLocal.known_type(src/vm.h), the compiler's ownknown_types[reg], supplemented by orderedDiamondScopeTypeFactassignment snapshots so reassignment changes resolution only after that assignment — or a parameter (or a local initialized from one) given an explicit union annotation (pet: Dog | Cat) or a representable control-flow join such aspet = flag ? Dog.new() : Cat.new()—DiamondScopeLocal.known_type_set(src/vm.h), the same snapshot idea applied to the compiler's ownknown_type_sets[reg], decoded against the owning function's owntype_sets[]table (a set index means nothing against any other function's).self's own enclosing class comes fromDiamondFunction.owner_classviaDiamondFunction.body_end(src/vm.h, populated at every function-closing site alongsidedeclaration_startso even a zero-parameter, zero-local method — a very commonself.foo()one-liner shape — still has a real body extent to locateselfwithin);receiver_lookup_methodthen walks each candidate class and its superclass chain by name, mirroringlookup_method/lookup_singleton_method(src/vm.c) at the source level. A union receiver resolves against every class-kind member that defines the method: hover shows one signature when every match agrees on it textually, orClassName#signatureper match when they differ; go-to-definition returns aLocation[]when there's more than one match (singleLocationotherwise, unchanged); completion lists every candidate's own methods, undeduplicated. Unions can come from explicit source annotations or from compiler-synthesizedif/unlessand ternary joins when every path has a representable known type. Synthesized sets remain advisory: they can prove a compatible annotation and eliminate its runtime check, but an incompatible set retains the historical runtime check rather than turning formerly dynamic code into a compile error. A class instance-variable receiver also resolves when every assignment to that field agrees on one concrete class; conflicting or unknown assignments deliberately erase the candidate rather than guessing. Grouping parentheses around any otherwise resolvable receiver are ignored. Repeated indexing from a local explicitly typed as a nestedArrayresolves to its eventual class element type; the same applies to a non-generic top-level, singleton, or single-class instance-method call with a structurally known array return. Generic calls also work when their class bindings can be inferred by the same exact-type- variable rule used for direct chains, or supplied explicitly as class unions or nestedArrayshapes ending in a class union. Inference also descends through matching parameterized shapes such asArray[T]versus a localArray[Pet | Leaf], orHash[String, T]versusHash[String, Pet | Leaf]. Constructor witnesses for a shared variable can join representable class unions, including repeated parent parameters such asPoint[T] < Pair[T, T]. Conflicting invariant arguments and unrepresentable editor shapes remain unresolved. Own constructors also retain child-only parameters, including with fixed parents. An inherited constructor missing a child binding stays unresolved unless explicit type arguments supply it. DirectHash[K, V]indexing with.remains unresolved because its real result isV | Nil; with safe navigation (pets["rex"]&.) the final lookup resolves toV, since a missing key never reaches the method (see "Safe navigation" below). Saving a declared or structurally inferred Hash lookup in a local preserves that nullable type. Existing nil comparisons now expose the compiler's branch-local proof to the editor: afterpet = pets["name"],if pet != nilorunless pet == nilenables completion, hover, and definition forpetinside the non-nil branch. The non-nilelseofif pet == niland continuation afterreturn if pet == nilwork too. Compound guards follow the compiler's existing conjunction/disjunction rules. Nil and uncertain branches, joins, and reassignment restore their own facts; a nullable or mixed primitive/class local does not silently drop the unresolved alternatives. Truthiness guards (if pet,unless pet, negation, andreturn unless pet) also publish branch facts. Nil is removed from the truthy view; Bool stays in both views because it includes true and false. The falsey view retains only Nil and Bool, so a mixedPet | Bool | Nildoes not resolve as Pet insideif pet. On a declared type these are checked compiler facts, as with a!= nilguard (see docs/types-and-errors.md); on an inferred one they stay editor-only. An imported factory such asdef make_pets() = {"name": Pet.new()}needs no return annotation:pet = make_pets()["name"]retainsPet | Nilfor the editor, and the explicit nil guard resolves its non-nil receiver. Hash literals can also combine known values and inferred factory values. Unknown key or value graphs stay unresolved. These inferred facts never become checked compiler types or remove runtime return checks. Unchanged non-nil/nil branch views rejoin the original nullable editor graph, so repeated guards still work; reassignment, unknown paths, and tracked alias writes discard stale graphs. Short-circuit expressions also publish facts while compiling the right operand:pet && pet.bark()uses the truthy view, andpet == nil || pet.bark()uses the non-nil view. Nested expressions follow the same rules. The surrounding facts are restored afterward; tracked writes discard invalidated graphs instead of restoring stale proofs. As with branch guards, a declared type gets checked facts for the right operand and an inferred one only editor facts. Indexing an inferred Array call result, directly or through a local, also preserves its element graph when assigned to another local, including repeated indexing through nested Arrays. These facts stay editor-only; unknown element graphs and directly chained nullable Hash indexing remain unresolved. Safe navigation: left of&.the receiver resolves through its non-nil view, for completion, hover and definition alike. AT | Nillocal or parameter, a call declared-> T | Nil, and a final Hash lookup all resolve as T. Only that outermost receiver drops Nil:a&.b()is itself nullable, soa&.b().stays unresolved whilea&.b()&.resolves, and an inner plain.link stays as strict as before. The formatter indents a leading&.line as a chain link, like a leading.. Array literals also carry inferred factory element graphs, including nested literals and mixtures of known and inferred class results. Locals and unannotated returns retain the richer editor graph alongside the compiler's broader checked shape. An unknown element suppresses the graph; tracked indexed writes and pushes discard it for the mutated local and its aliases. A literal spread such as[first, *make_pets(), last]joins the prefix, spread element, and suffix graphs by the same editor-only rules. Inferred and checked Array shapes can mix, including nested Arrays and class unions; an unknown prefix, spread element graph, or suffix leaves it unresolved. Arrayreverse,uniq,compact,take,drop,sort,sort_by,select, andrejectalso preserve these inferred element graphs through direct chains, locals, and unannotated wrappers, including nested Arrays and class unions. The copied result has its own outer collection identity: replacing an element in the source does not discard the copy's graph, while tracked writes or pushes to the copy invalidate its graph and those of its aliases. These facts remain editor-only; unknown source elements stay unresolved. A filter or sort callback cannot change the element graph, soselect,reject, andsort_bykeep it, including when written with adoblock.mapandflat_mapover an Array with a known element graph take their result elements from thedoblock's own inferred result (pets.map() do |pet| build_pet() end); a block whose result is unknown, declared, or varies by earlyreturn/nextleaves the result unresolved. A bare unannotated function name works the same way (pets.map(make_pet)) through its inferred result. A block on a native collection method (each,map,select, ...) over an Array or Hash whose element graph is editor-only types its parameters from that graph, as tooling facts only:pets.each() do |pet| pet.bark() endcompletes, andmap() do |pet| pet endkeeps the graph. The checked block-parameter facts are used instead whenever the receiver has any. A union receiver may supply the array-valued method even when its candidates return different element graphs. For example, if imported factories returnArray[Pet]andArray[Leaf],factory.pets()[0].offers methods from bothPetandLeaf. Nested indexing and the native Array transforms above keep each candidate's graph, and generic methods substitute their own bindings before combining the element classes. Every candidate must support the full indexing chain: a missing method, unknown result, nullable result, or incompatible nesting leaves it unresolved. Call results can be receivers recursively:Branch.new().leaf().ping(), a top-levelmake_branch().leaf(), and a singleton factory such asFactory.build().leaf()resolve through each link's return type, preferring an explicit class or class-union annotation (DiamondFunction.return_type_set) and falling back to a same-shaped inferred one (DiamondFunction.inferred_return_type_set) when the function has no annotation at all but its own body is simple enough to have a known type at compile time (a single-expressiondef make_branch() = Branch.new(), say) -- the identical inferencecompile_blockalready does for an unannotated block's own return type, just also applied tocompile_definition. This inferred field exists purely for this resolution; unlike the explicit one, it plays no part in real compile-time semantics (structural interface conformance, generic instantiation) precisely so this stays a pure tooling improvement with zero behavior-changing risk.Class.new()is the one intrinsic return rule. Every arm of a union receiver must define a link with a usable (explicit or inferred) class return, so one uncertain arm makes the rest of the chain unresolved. A body whose control flow branches into more than one class with no shared annotation (anif/case) resolves as a union either way it's expressed: a bare trailingif/caseexpression gets it from the exact same per-branch merge (merge_flow_types) an assigned-to-a- localif/ternary already synthesizes, since that merge writes straight onto the expression's own destination register regardless of what uses it afterward; a function using explicitreturnstatements across separate branches instead --body_resultalone can never see those -- gets it fromcompile_return's own accumulated union of everyreturn valueit saw, merged in alongside the trailing- expression case rather than replacing it. An inferred result also survives a simple local assignment (pet = build_pet(); pet.bark()), including for unannotated singleton and instance methods and when the factory comes fromrequire; this uses a separate tooling-only scope fact and never enters the compiler facts used for type checks or opcode selection.if/unless,case, and loop joins preserve that fact only when every runtime path carries the same fact (including structurally equivalent graphs independently inferred in separate branches, and the zero-iteration andbreakexits of a loop); conflicting paths clear it rather than retaining the last branch compiled. An inferred union receiver can likewise publish an assigned method result when every class arm defines the method with a usable declared or inferred return; one missing or unknown arm makes the result unresolved. Generic functions, instance methods, and singleton methods apply the same tooling-only inference after substituting explicit or argument-inferred type-variable bindings; if any binding remains unresolved, no receiver fact is published. A directly chained generic function or method call also resolves when its explicit bindings are class unions, optionally nested inArray, or when each binding can be inferred from a parameter that is exactly that type variable and a class-union receiver argument. Other source-level type arguments and more complex inference shapes remain deliberately unresolved. An unannotated generatedattr_readeralso publishes a tooling-only class return when every discovered write to its field has the same concrete class. This works throughrequire, chained calls, and assignment to a local. Unknown or conflicting writes (including generated writers and later class reopenings) suppress it. Inherited readers resolve against the call's receiver class, including different sibling subclasses that initialize an otherwise unwritten field. Every ancestor's final writes are checked, including late reopenings; conflicting parent/child initializer writes remain unresolved even if the child overridesinitialize. Unions require a known result for every receiver arm. Explicit reader annotations still take precedence; inferred reader results do not add runtime checks or satisfy typed structural interfaces. Unannotated factories and wrappers preserve those advisory call-result types through their trailing expressions and explicitreturnstatements, including nested wrapper calls and imports. Every reachable return path must be known; an unknown early return or trailing value suppresses inference. Declared return annotations still win, and returns from nested definitions or blocks do not leak into the enclosing function's facts. Nonlocal block returns remain conservatively unresolved. These facts do not become runtime return contracts. Completion, hover, definition, and references refine inferred forward-call returns using completed compiler snapshots, so short factory/wrapper chains can resolve even when their callees are declared later. Refinement stops when return facts stabilize and is limited to eight additional passes. Unknown return paths and unsupported recursive graphs remain unresolved; very long chains may exceed the refinement budget. Runtime compilation, diagnostics, and symbol indexing retain the ordinary two-pass compile. Completion, hover, and definition share one cached analysis of the most recently requested source bundle. Each request reloads imports and open document overrides before comparing the complete source, so root edits, unsaved import edits, and disk changes invalidate the cache. Requests keep their own source maps. A failed compile never returns a stale cached result. Capturing/boxing a local preserves the separate tooling fact through the capture cell, including when the loaded value is assigned to another local. Module-qualified class names work as receivers (Shapes::Table.new().describe(),Outer::Inner::Deep.new().dive()), including explicit generic construction such asRecords::Box[Models::Pet].new(...).value().. Qualified names also work in nested class/Array arguments and explicit function or method type arguments. Inherited readers and indexed returns retain those bindings across imports; whitespace around::is accepted, and names are matched by their full path. Unsaved dependency edits refresh member lists and definition locations. A module's singleton method (Shapes.build().describe(),def self.buildormodule_function) publishes its result class like a top-level function. A call on a module with no resolvable return stays unresolved. Any other receiver this can't resolve falls back to the ordinary "not found" result instead of a guess. All three require the document to currently compile cleanly — otherwise they returnnull/empty rather than a stale result; the document's own diagnostics already say why.
- Hover shows the reconstructed signature (parameter types, return
type, which parameters are optional),
textDocument/completion(lsp/completion.c) suggests every top-level function, class, module, and interface in the whole compiled program (not just this document's own —lib/core.d6's prelude and anything pulled in viarequireare all valid to type) plus every local variable/parameter actually in scope at the cursor — real lexical scoping, backed by a genuine (if narrow) per-function symbol table the compiler now builds as a side effect of compiling:DiamondFunction.scope_locals(src/vm.h) records every local's own name and the exact byte range (in the compiled buffer) it's valid for, taken directly from the compiler's ownLocalbookkeeping at the two points a scope actually closes (a function body finishing incompile_definition, arescueclause finishing incompile_begin— d6'sif/while/unless/untildeliberately don't open their own scope, matching Ruby, so those needed no extra handling). Mapping the cursor's own line/column into that same compiled-buffer coordinate system needed a new inverse ofdiamond_resolve_diagnostic_location:diamond_resolve_source_ position(src/compiler.h) walks the same segment table the other direction. A name's valid range is checked directly against the cursor's own offset — no lexical-nesting bookkeeping needed for a nesteddefto see its enclosing function's own locals (real closure capture visibility), since the outer function's own recorded range already spans everything nested inside it, nesteddefs included. Doesn't filter by whatever's already typed (every mainstream client already does that client-side against the full list this returns) or suggest language keywords; requires a clean compile, same rule as hover/definition/documentSymbol. When the cursor sits right afterreceiver.(method name partially typed or not yet typed at all), also appends the receiver's own class's methods and its superclass chain's, via the samelsp/receiver.cresolution hover/definition use — not deduplicated against each other or the rest of the list, same as nothing else here dedupes either. When that receiver is a built-in value instead — a String, Integer or Float literal, or a local or parameter whose known type isString,Array,Hash,IntorFloat— it appends that type's methods: the VM's native names (diamond_native_member_names,src/vm.c) plus the prelude extension functions the VM dispatches by name (array_x,hash_x,string_x,integer_x/float_x/numeric_x,enumerable_x, each also under adiamond_prefix), deduplicated, with?added to Bool-returning ones. The receiver may also be an Array or Hash literal, or a call whose result type is known: a native method with one fixed result type (name.strip().,value.to_s().,text.split(" ").), a prelude extension function or top-level function with a declared return type, applied to any receiver of those forms. Element types (items[0].) and methods whose result varies are not resolved.tests/lsp_test.shcalls every offered name on a real value, so a name the VM does not implement fails CI.workspace/symbol(lsp/workspace_symbol.c) recursively walks the workspace root given viainitialize(skipping dotfiles/dotdirs —.gitand friends), compiles every*.d6file it finds the same way an open document is (an open file's own live, possibly-unsaved buffer is preferred over disk, same as everywhere elserequireresolves), and returns every top-level function, class, module, and interface actually declared in that file itself whose name contains the query as a case-insensitive substring. No caching across separate requests — real editors only send this on an explicit "go to symbol in workspace" action, not on every keystroke, so re-walking and re-compiling the whole workspace each time is an accepted v1 tradeoff, not an oversight. One file that fails to compile doesn't hide every other file's symbols — it's silently skipped.textDocument/references(lsp/references.c) finds every workspace occurrence of the top-level function, class, module, or interface name under the cursor — the same globally-unambiguous declaration kinds hover/definition/documentSymbol already single out — by walking the workspace the same wayworkspace/symboldoes and tokenizing each compiled file's own buffer for the name in a resolvable position: a call/access site (immediately followed by(or.), a type position (immediately preceded by:,|, or<), or a class/module/interface declaration header (immediately preceded byclass/module/interface). A candidate is dropped if a lexical local of the same name is in scope there (receiver_name_is_local,lsp/receiver.h), ruling out a keyword-argument label, a hash key, or a shadowing local — the same conservative bias every other receiver-aware feature here already has. Explicit receiver calls and method or nested-function declarations are excluded from the global scan. Selecting one of those occurrences, or a shadowing local/parameter, returns no global references or rename edits even when a reachable global shares the name. This is deliberately name-based, not a full alias-aware resolver: a bare-name reference with none of those three adjacent shapes (a class passed as a first-class value, say) isn't found — a known, deliberate under-approximation, not a bug (seelsp/ references.h). Results across files are deduplicated by resolved uri/line/column, since a sharedrequired file's own content is rediscovered once per requiring file scanned. Returnsnullwhen the cursor isn't on such a name or the origin document doesn't currently compile cleanly, matching hover/definition/completion's own rule.textDocument/rename(lsp/references.c, sharing its scan withtextDocument/referencesdirectly — same file, same underlying helper, differing only in how the results get built into JSON) runs the exact same workspace scan described above, then returns aWorkspaceEdit({"changes": {uri: TextEdit[], ...}}) replacing every occurrence found withnewNameinstead of aLocation[]pointing at them.newNameadditionally has to lex as exactly one identifier token consuming the whole string — a keyword, an empty string, a qualifiedA::Bname, or anything with embedded whitespace is rejected outright (null), since accepting one would hand back an edit guaranteed not to recompile. No collision detection against an existing same-named symbol at the rename's own target scope — same deliberately name-based, conservative-scope cuttextDocument/referencesitself already makes, not a gap specific to rename. d6 enforces no class-vs-function naming-case convention at the language level, so neither does this: renaming a class to a lowercase name is accepted the same way declaring one with a lowercase name already is. NotextDocument/prepareRename-- a client without it just lets the user type a name unconditionally and callsrenameanyway, which still correctly returnsnullwhen the position isn't renameable.textDocument/formatting(lsp/formatting.c) normalizes every physical line's own leading indentation todepth * 2spaces, trims trailing whitespace, adds the spacing around commas and operators described below, and collapses blank-line runs -- deliberately not a full AST-based pretty-printer: the native compiler retains no AST at all (docs/ roadmap.md's "Compiler representation"), so a real structural formatter would need a whole new tree-building parser kept in sync with the real grammar, a materially bigger undertaking than tracking nesting from the same token stream every other handler here already uses.depthcounts opendef/class/module/interface/struct/begin/case/loop/doblocks, block-formif/unless/while/until(never a postfix modifier -- determined by whether the keyword is the first token on its own line or immediately preceded by something other than a value-end token, e.g. an assignment, an open bracket, or a comma; a value-end token directly before it means postfix instead,return 1 if n <= 1), and open brackets.else/elsif/when/rescue/ensureand a line's own leading closers dedent that one line without changing the running depth used for subsequent lines. Three shapes get their own handling: an endlessdef name(...) -> Type = expr(orclosure, which sharesdef's exact grammar) needs noendat all;loop/while/until's own optional trailingdois absorbed syntax for the same one level, not a second nested block (src/compiler_flow.c'sconsume_loop_start); and adefsignature with no body at all is legal exactly inside aninterface's own body.value.class()is the one keyword the compiler itself accepts as a method name right after a dot (src/compiler_names.c's ownparse_invoke), so it's excluded from opening a level there too. Between two tokens on the same line, a comma takes no space before it and one after it (unless a closing bracket follows), and an assignment, compound-assignment, comparison (==,!=,<=,>=,<=>) or logical (&&,||) operator gets a space on each side where it has none:x=1becomesx = 1,f(a,b)becomesf(a, b). Existing runs of spaces are never collapsed, so aligned columns stay aligned; arithmetic operators, block bars, unions and ranges are left alone because the same token means a unary or non-spacing form elsewhere. Operators used as method names (def ==(other),a.==(b)) and the=of a setter header (def value=(v),def []=(k, v)) are recognized and untouched. Only the whitespace between two tokens is ever edited, so no string, comment or identifier changes. A run of two or more blank lines outside a string literal collapses to one.textDocument/rangeFormattingreturns the same edits restricted to those starting on the requested lines; the nesting depth still comes from the whole document, so a range that starts inside a block indents correctly. A range ending at column 0 of a line does not include that line.tests/formatting_test.cchecks the rules and, over every.d6file in the tests, examples, packages andlib(2,500+ files), that the token stream is unchanged apart from NEWLINE counts and that a second pass proposes nothing. Lines that start (or whose newline falls) inside a multi-line string literal are string content and are never re-indented or trimmed. Returnsjson_null()rather than a guess when the source contains a lexer error or the tracked depth doesn't balance to exactly zero by EOF -- a real grammar shape this pass doesn't yet model should mean no edits, not a corrupted one. No on-type formatting, and no reflowing a long line or reordering anything -- seelsp/formatting.h's own doc comment for the full contract.shutdown/exit— the ordinary LSP lifecycle;exit's process exit code is 0 ifshutdownwas requested first, 1 otherwise, per spec.- Any other request gets a JSON-RPC
MethodNotFound(-32601) error; any other notification is silently ignored.
.div templates (diagnostics only)
Any document whose path/uri ends in .div (packages/div — see its own
README for the tag grammar) is not raw d6 source, so it's never
handed to diamond_compile as-is; lsp/div.c's div_translate first
turns it into an ordinary d6 source buffer implementing the same
tag semantics packages/div/lib/div/compiler.d6's own Div.compile_source
does (a hand-ported C mirror, kept behaviorally in sync by hand — there's
no shared implementation between the two, see that file's own header
comment for the exact grammar and scope cuts), then compiles that
through diagnostics_compute's ordinary no-require-bundling path (a
template never requires anything of its own). Any resulting diagnostic
gets mapped back through a line-position table div_translate builds
alongside the generated source (one entry per generated line, recording
which .div source line/column it came from — {0,0} for a line with no
single corresponding template position, e.g. the inlined escape-helper
boilerplate, which falls back to anchoring at the template's own start)
instead of through the segment-table machinery every other document's
diagnostics use — there's no require bundle here to walk. A tag body
that itself spans multiple lines (a multi-line <% %>/<%= %>/<%== %>)
still maps each of its own physical lines individually, since the
generated code is a verbatim copy of the tag's own text.
Only diagnostics work for .div documents — hover, go-to-definition,
document symbols, completion, and workspace symbol search all still
treat them as ordinary (non-)d6 source and behave accordingly
(mostly: finding nothing, since the raw ERB-tag-mixed-HTML text they'd
otherwise tokenize isn't valid d6 syntax). Extending any of those to
work inside a tag's own embedded expression would need the same kind of
position-mapping div_translate already does for diagnostics, threaded
through each of those handlers separately — a larger, currently
unstarted slice.
This is a server-side capability, so clients must send .div documents to
the server. The bundled VS Code extension assigns .div and .html.div the
diamond-template language id and sends their open, change, and close events.
It deliberately does not apply d6's TextMate grammar to the surrounding
HTML. Other language providers remain registered only for ordinary d6
documents, matching the diagnostics-only template support.
Each .html.div file is also translated and compiled in isolation —
a call to another template's own generated function (a partial, e.g.
<%== author_books_table_html(books) %>) reports "undefined function"
here even though the real, divc-compiled output works fine once
required alongside its partial by the app that actually renders it
(require's flat top-level namespace is what makes that work at
runtime — see packages/div/README.md). This is a real, known false
positive, not a crash or a wrong location: the reported position still
correctly lands on the unresolved call itself. Resolving it would mean
teaching the LSP which other templates a given one is normally
required alongside, which nothing currently tracks (a .div file has
no require line of its own to read that from).
A document's text is compiled exactly the way src/main.c's own
run_source compiles a file natively: required files resolved and
bundled in via diamond_load_program_with_override (src/loader.h),
then lib/core.d6 prepended and a #line 1 reset so the prelude's own
line numbers never leak into a reported diagnostic's position. A
diagnostic's line/column are re-resolved back through
diamond_load_program's own segment table
(diamond_resolve_diagnostic_location, shared with src/main.c's CLI
diagnostic printing — not a separate reimplementation) so they land on
the right line even when a required file's inlined content shifts
everything after it.
A require resolving to a file that's also currently open sees that
document's live buffer, not stale on-disk content — even before it's
saved. diamond_load_program_with_override's optional override
callback (DiamondSourceOverride, src/loader.h) is called with every
required file's own canonicalized path before falling back to disk;
lsp/'s every entry point that resolves require (diagnostics,
hover, go-to-definition, document symbols) passes
document_resolve_source (lsp/document.h), which looks that path up
against the open-document table and hands back a copy of its current
buffer if found. diamond_load_program itself (used by the CLI, the
REPL, ProgramBuilder's native bridge) is an unchanged thin wrapper
passing no override — this is opt-in, lsp/-only behavior, not a
change to how requires resolve natively.
Editing an open dependency also correctly re-diagnoses whatever else
requires it, live. lsp/dependencies.h's DependencyTable is a
reverse index — for every open document, which on-disk paths its last
compiled bundle actually pulled in (diagnostics_compute's own
out_dependency_paths, built from the bundle's segment table, so
chains resolve in one lookup: if X requires A requires B, X's own
recorded dependency set already includes B directly, since
diamond_load_program's bundle segments are fully transitive).
lsp/main.c's publish_diagnostics looks up who currently depends on
a document right after publishing its own diagnostics, and republishes
theirs too — each of those requires now resolving through the
just-changed document's live buffer via the override above, so a
dependent's diagnostics genuinely reflect the edit, not a stale
snapshot from before it.
Building and connecting an editor
make lsp
produces build/d6-lsp. Point any editor's "custom language server"
configuration at that binary with stdio as the transport (no --stdio
flag needed or accepted — stdio is the only transport this server
speaks). The exact configuration step is editor-specific; consult your
editor's LSP client documentation for how it registers a server for a
new file type/extension (.d6).
make test-lsp runs tests/lsp_test.sh, which drives the real binary
over its real stdio transport via a bash coproc — the same
no-Python/no-Node convention every other test script in this repo
follows — through the full initialize → didOpen → didChange →
didClose → shutdown → exit lifecycle, hover/definition/
documentSymbol at a declaration, at a call site, and across a require,
all three correctly returning nothing for a local variable or a document
that doesn't currently compile, document symbols excluding the prelude
and anything pulled in via require, plus the unknown-method and
exit-without-shutdown edge cases.
It also runs tests/v3_editor_test.sh, a self-contained version of
SkindicateV3's imported schema, inherited reader, and forward-wrapper query
workflow. The test checks completion, hover, and definition sharing one
analysis, then verifies fresh results after unsaved transitive import edits,
broken imports, closing an import, and disk edits. It needs no sibling checkout,
installed cuts, or database. Method hover signatures cover required, optional,
variadic, and block parameters, including singleton methods.
editors/vscode/extension.js wires all three up client-side too, via
vscode.languages.registerHoverProvider/registerDefinitionProvider/
registerDocumentSymbolProvider — see its own file and
editors/vscode/README.md.
What's deliberately out of scope so far
receiver.method(...)support beyond the resolvable receiver forms above — typed function and method call results now compose recursively, but an unannotated/dynamic result still deliberately falls back to ordinary name-based behavior rather than guessing. Union receivers cover explicit annotations and representableif/unlessor ternary control-flow joins; unrepresentable joins (unknown paths or conflicting parameterized forms) stay unresolved; see the hover/definition/documentSymbol bullet above for why.- Workspace symbol results for a method name reached through
receiver.method(...), or for anything needing scope resolution beyond a single compiled program's own function/class/local tables. - Incremental sync —
textDocumentSynconly ever advertisesFull. d6 has no incremental-recompile story at all yet (every compile is a freshdiamond_compilecall over the whole combined buffer), soIncrementalsync would only add bookkeeping for no benefit until that changes. - Any transport but stdio — no TCP/socket mode. Every mainstream editor already launches custom language servers over stdio, so there's no immediate need for another one.
Each of these is a plausible next slice, sized independently — see
docs/roadmap.md.