starlsp

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 16, 2026 License: Apache-2.0 Imports: 20 Imported by: 0

README

starlsp

A language server for Starlark, written in pure Go.

Starlark has no single dialect. Bazel, Buck, Tilt, and Starkite each add their own builtins, their own meaning for load(), and their own rules. starlsp knows the language specification and nothing else. A dialect supplies the rest through one small interface, so every host gets the same editor features without writing a language server.

go install github.com/M31-Labs/starlsp/cmd/starlsp@latest
starlsp --probe

Why another one

tilt-dev/starlark-lsp came first and proved the idea. It is worth reading. These are the reasons it did not become the foundation for this one, each of them checkable:

tilt-dev/starlark-lsp starlsp
Parser binding smacker/go-tree-sitter — cgo, pinned to a 2022 commit gotreesitter — pure Go
Cross-compilation needs a C toolchain per target GOOS=… go build
Document sync full document per edit incremental
Builtin names Python stub files, maintained by hand introspected from starlark.Universe
Type methods stub files introspected from the runtime values
Dialect extension stub files a typed Host interface
Last code commit July 2024 —
Protocol types go.lsp.dev/protocol v0.11.2 no protocol dependency

Capabilities, counted from what each server advertises at initialize:

Capability tilt-dev starlsp
Diagnostics ● ●
Completion ● ●
Hover ● ●
Signature help ● ●
Go to definition ● ●
Document symbols ● ●
Find references ○ ●
Document highlight ○ ●
Rename, with prepare ○ ●
Workspace symbols ○ ●
Folding ranges ○ ●
Selection ranges ○ ●
Document links ○ ●
Semantic tokens ○ ●

Six capabilities become fourteen. The new ones are grouped around a reference index the resolver does not keep: go.starlark.net/resolve establishes which identifiers bind to which variable, then discards the back-references. Building that index is what makes find-references, rename, and highlight exact — a local x in one function is not the same variable as a local x in another, and matching by text would merge them.

Two parsers, one opinion

The server runs two parsers because they answer different questions.

gotreesitter answers structural questions on every keystroke. It tolerates a half-typed buffer, which is exactly when an editor is most useful. It supplies the outline, folding, semantic tokens, and the caret context that completion needs.

go.starlark.net answers every question about correctness, because it is the same parser and resolver a Starlark host actually runs. Diagnostics, name binding, and scope come from there and nowhere else.

The division matters. A language server that reimplements Starlark's scope rules eventually disagrees with the runtime, and the disagreement is always the server's fault. Here it cannot happen: the core never gets a vote on whether a file is correct, only on what shape it currently has.

Extending it for a dialect

Two methods are required. Everything else is an optional interface, checked with a type assertion, so a host implements only what it has.

type myDialect struct{}

func (myDialect) Name() string { return "mydialect" }

func (myDialect) Globals() []starlsp.Symbol {
	return []starlsp.Symbol{{
		Name:      "greet",
		Kind:      starlsp.KindFunction,
		Signature: "greet(name)",
		Returns:   "None",
		Doc:       "Print a greeting.",
	}}
}

func main() {
	srv, err := starlsp.New(starlsp.Options{
		Host: starlsp.Hosts(starlsp.NewVanilla(), myDialect{}),
	})
	if err != nil {
		log.Fatal(err)
	}
	log.Fatal(srv.Run())
}

That alone gives greet completion, hover with its signature, signature help with its parameter, correct semantic-token colour, and — because the resolver is told the name exists — no false "undefined" diagnostic.

The optional interfaces:

Interface Adds
MemberProvider completion after a dot
TypeResolver what p is, after p = fs.path(...)
LoadResolver go to definition across load()
Linter diagnostics belonging to the dialect
Documenter prose and signatures held elsewhere

Hosts(a, b, …) composes hosts. Later hosts win on a name collision, so a dialect layers over the specification without either knowing about the other.

A note on introspection

Prefer introspecting your runtime over writing a table. The vanilla host does: its globals come from starlark.Universe and its methods from calling AttrNames on a zero String, List, Dict, Set, and Bytes. A build of go.starlark.net that adds a method gains completion for it with no change here.

A hand-written table tracking a moving API rots. The only hand-written data in this package is prose for the Starlark specification, which is frozen.

Checking a build

--probe prints what the server would offer and exits, so a missing completion can be diagnosed without an editor.

$ starlsp --probe
starlsp 0.1.0

host           starlark
parser         starlark (gotreesitter, pure Go)
globals        49
hooks          members, types

types       10  bool bytes dict float int list range set str tuple
functions   20  abs all any chr dir enumerate fail getattr hasattr hash len max min ord print repr reversed sorted type zip
constants    3  False None True
keywords    16  and break continue def elif else for if in lambda load not or pass return while

members
  bytes            1
  dict             9
  list             7
  set             12
  string          35

Editor setup

VS Code

Any generic LSP client works. With vscode-languageclient, run starlsp with no arguments over stdio.

Neovim
vim.filetype.add({ extension = { star = "starlark", bzl = "starlark" } })

vim.lsp.config.starlsp = {
  cmd = { "starlsp" },
  filetypes = { "starlark" },
  root_markers = { ".git" },
}
vim.lsp.enable("starlsp")
Helix
[language-server.starlsp]
command = "starlsp"

[[language]]
name = "starlark"
scope = "source.star"
file-types = ["star", "bzl", "sky"]
comment-token = "#"
indent = { tab-width = 4, unit = "    " }
language-servers = ["starlsp"]
Zed
{
  "lsp": { "starlsp": { "binary": { "path": "starlsp" } } }
}

Status

Version 0.1.0. The protocol surface and the Host interface are settled enough to build against; both may still change before 1.0. Issues and dialect hosts are welcome.

License

Apache 2.0. See LICENSE.

Documentation

Overview

Package starlsp implements a Language Server Protocol server for Starlark.

The server splits work between two parsers, because they answer different questions. gotreesitter answers structural questions on every keystroke: it tolerates a half-typed buffer, which is exactly when an editor is most useful. go.starlark.net answers every question about correctness, because it is the same parser and resolver a Starlark host actually runs — so a diagnostic shown in an editor is one the host would report.

The core knows the Starlark specification and nothing else. A dialect — Bazel, Buck, Tilt, Starkite — supplies its builtins, its load() semantics, and its own lints through the Host interface. See host.go.

Everything here is pure Go. There is no cgo, and therefore no C toolchain and no cross-compilation cliff when embedding the server in another program.

This file carries the wire protocol: JSON-RPC 2.0 over Content-Length framed stdio, plus the LSP structures the server exchanges. The types are hand-written rather than taken from a protocol library, so embedding this server adds one dependency for parsing and none for the protocol.

Index

Constants

View Source
const Version = "0.1.0"

Version is reported to the client at initialize.

Variables

This section is empty.

Functions

This section is empty.

Types

type CompletionItem

type CompletionItem struct {
	Label         string             `json:"label"`
	Kind          CompletionItemKind `json:"kind,omitempty"`
	Detail        string             `json:"detail,omitempty"`
	Documentation *MarkupContent     `json:"documentation,omitempty"`
	InsertText    string             `json:"insertText,omitempty"`
	SortText      string             `json:"sortText,omitempty"`
}

type CompletionItemKind

type CompletionItemKind int

type Diagnostic

type Diagnostic struct {
	Range    Range              `json:"range"`
	Severity DiagnosticSeverity `json:"severity,omitempty"`
	Source   string             `json:"source,omitempty"`
	Message  string             `json:"message"`
}

type DiagnosticSeverity

type DiagnosticSeverity int

type Document

type Document struct {
	// contains filtered or unexported fields
}

Document holds one open buffer, its line index, and its parse tree.

The tree is produced by gotreesitter and is always available, even when the buffer does not parse as valid Starlark. That is the whole reason the structural half of this server uses tree-sitter: a language server is most useful exactly when the buffer is half-typed.

type DocumentHighlight

type DocumentHighlight struct {
	Range Range                 `json:"range"`
	Kind  DocumentHighlightKind `json:"kind,omitempty"`
}

type DocumentHighlightKind

type DocumentHighlightKind int

DocumentHighlightKind distinguishes a read from a write at a location.

type DocumentLink struct {
	Range   Range  `json:"range"`
	Target  string `json:"target,omitempty"`
	Tooltip string `json:"tooltip,omitempty"`
}

type DocumentSymbol

type DocumentSymbol struct {
	Name           string           `json:"name"`
	Detail         string           `json:"detail,omitempty"`
	Kind           SymbolKind       `json:"kind"`
	Range          Range            `json:"range"`
	SelectionRange Range            `json:"selectionRange"`
	Children       []DocumentSymbol `json:"children,omitempty"`
}

type Documenter

type Documenter interface {
	Document(owner, name string) (Symbol, bool)
}

A Documenter supplies prose and signatures the server cannot introspect.

A Starlark builtin carries neither its parameter names nor its description, so a host that has documentation elsewhere — reference files, stub files, Go doc comments — surfaces it here. Symbols returned from Globals and Members may carry their own documentation instead; this hook exists for hosts whose documentation is not attached to the values.

type FoldingRange

type FoldingRange struct {
	StartLine int    `json:"startLine"`
	EndLine   int    `json:"endLine"`
	Kind      string `json:"kind,omitempty"`
}

type Host

type Host interface {
	// Name identifies the dialect in server logs and in the initialize
	// response, for example "bazel" or "starkite".
	Name() string

	// Globals returns the names the host predeclares, beyond the universe the
	// Starlark specification defines. The server treats these as defined, so
	// they are also what stops the resolver reporting them as undefined names.
	Globals() []Symbol
}

A Host teaches the server about one Starlark dialect.

Starlark has no single dialect. Bazel, Buck, Tilt, and Starkite each add their own builtins, their own meaning for load(), and their own rules about what a well-formed file looks like. The core of this server knows the language specification and nothing else; a Host supplies the rest.

Only Name and Globals are required. Everything else is an optional interface, checked with a type assertion, so a host that merely adds a few builtins implements two methods rather than six.

The vanilla host in this package implements Host alone, and is what the standalone binary uses when no dialect is configured.

func Hosts

func Hosts(hosts ...Host) Host

Hosts composes several hosts into one.

Names from later hosts take precedence on collision, which lets a dialect layer over a base without either knowing about the other. Optional interfaces are honoured for every member: a composite answers a member query from the first host that has an answer.

type Kind

type Kind int

Kind classifies a name for the editor.

const (
	KindUnknown Kind = iota
	KindFunction
	KindMethod
	KindNamespace // a module or package: fs, http
	KindType      // a constructible type: Path, Response
	KindProperty  // an attribute read without a call
	KindVariable
	KindConstant
	KindKeyword
	KindParameter
)

The kinds a host can produce. They map onto LSP completion item kinds and onto semantic token types.

type Linter

type Linter interface {
	Lint(doc *Document, file *syntax.File) []Diagnostic
}

A Linter reports diagnostics that belong to the dialect rather than to the language, such as an entry-point convention or a permission rule.

The server calls it after its own analysis. File is nil when the buffer does not parse, so a linter that needs an AST must check for that and return nothing rather than guess at a broken buffer.

type LoadRequest

type LoadRequest struct {
	// Target is the literal text inside the load() call, without quotes.
	Target string

	// From is the filesystem path of the file containing the load(), or empty
	// when the buffer is not on disk.
	From string
}

LoadRequest describes one load() target to resolve.

type LoadResolver

type LoadResolver interface {
	ResolveLoad(req LoadRequest) (path string, ok bool)
}

A LoadResolver maps a load() target to a file.

This is the most dialect-specific hook in the interface. Bazel takes a label, Tilt takes a path, and Starkite takes either a path or a "namespace/name" identity pinned by a lockfile. The core never guesses; without this hook a load() target is highlighted but not navigable.

type Location

type Location struct {
	URI   string `json:"uri"`
	Range Range  `json:"range"`
}

type MarkupContent

type MarkupContent struct {
	Kind  string `json:"kind"`  // "markdown" or "plaintext"
	Value string `json:"value"` //
}

type MemberProvider

type MemberProvider interface {
	Members(owner string) []Symbol
}

A MemberProvider supplies the names reachable through a dot.

owner is either a namespace the host predeclared — "fs" — or a type name that a TypeResolver produced — "fs.path". A host that has no member structure does not implement this, and the server offers nothing after a dot rather than guessing.

type Options

type Options struct {
	// Host supplies the dialect. Defaults to NewVanilla, which is Starlark as
	// the specification defines it.
	Host Host

	// In and Out are the client transport. Both default to stdio.
	In  io.Reader
	Out io.Writer

	// Log receives server-side messages. It must never be Out, because
	// anything written there corrupts the protocol stream.
	Log io.Writer
}

Options configures a server.

type ParameterInformation

type ParameterInformation struct {
	Label string `json:"label"`
}

type Position

type Position struct {
	Line      int `json:"line"`
	Character int `json:"character"`
}

Position is a zero-based line and UTF-16 code-unit offset. The UTF-16 column is the protocol default and is what editors send.

type Range

type Range struct {
	Start Position `json:"start"`
	End   Position `json:"end"`
}

Range is a half-open span between two positions.

type SelectionRange

type SelectionRange struct {
	Range  Range           `json:"range"`
	Parent *SelectionRange `json:"parent,omitempty"`
}

SelectionRange is one step of expand-selection, linked to its parent.

type Server

type Server struct {
	// contains filtered or unexported fields
}

Server is a Starlark language server.

It is usable two ways. Run drives it over a transport, which is what the standalone binary does. A host application can also embed it and supply its own Host, which is the reason the type and its Options are exported.

func New

func New(opts Options) (*Server, error)

New builds a server. It touches no transport until Run is called.

func (*Server) Probe

func (s *Server) Probe() string

Probe renders what this server would offer, without a client.

It exists so a host's surface can be inspected directly. Anyone debugging a missing completion can see exactly which names and owners the configured host exposes, rather than inferring it from an editor.

func (*Server) Run

func (s *Server) Run() error

Run serves the client until the stream closes or the client exits.

type SignatureInformation

type SignatureInformation struct {
	Label         string                 `json:"label"`
	Documentation *MarkupContent         `json:"documentation,omitempty"`
	Parameters    []ParameterInformation `json:"parameters,omitempty"`
}

type Symbol

type Symbol struct {
	// Name is the identifier as written in source: "read_text".
	Name string

	// Owner is the namespace or type that holds it, empty for a global.
	Owner string

	// Kind classifies it for completion and highlighting.
	Kind Kind

	// Signature is the full call form when known: "fs.path(p)". The server
	// parses parameters out of it for signature help, so the parentheses
	// matter.
	Signature string

	// Returns is the result type, shown beside the name in completion.
	Returns string

	// Doc is markdown prose shown on hover.
	Doc string

	// Deprecated marks a name the editor should strike through.
	Deprecated bool

	// SortText overrides completion ordering. Leave empty for the default,
	// which sorts host globals after file-local names.
	SortText string

	// Detail overrides the right-hand text in the completion list. Leave empty
	// to let the server compose it from Signature and Returns.
	Detail string
}

A Symbol is one completable, hoverable name.

A host builds these from whatever it actually knows. Only Name is required; everything else improves what the editor shows and none of it is guessed by the server.

func (Symbol) Params

func (s Symbol) Params() []string

Params splits the parameter list out of Signature. It returns nil when the symbol has no signature or is not a call.

func (Symbol) Qualified

func (s Symbol) Qualified() string

Qualified renders the dotted path of a symbol.

type SymbolInformation

type SymbolInformation struct {
	Name          string     `json:"name"`
	Kind          SymbolKind `json:"kind"`
	Location      Location   `json:"location"`
	ContainerName string     `json:"containerName,omitempty"`
}

SymbolInformation is the flat symbol shape workspace/symbol returns.

type SymbolKind

type SymbolKind int

type TextDocumentIdentifier

type TextDocumentIdentifier struct {
	URI string `json:"uri"`
}

type TextDocumentItem

type TextDocumentItem struct {
	URI        string `json:"uri"`
	LanguageID string `json:"languageId"`
	Version    int    `json:"version"`
	Text       string `json:"text"`
}

type TextEdit

type TextEdit struct {
	Range   Range  `json:"range"`
	NewText string `json:"newText"`
}

TextEdit is one replacement within a document.

type TypeRequest

type TypeRequest struct {
	// Expr is the receiver expression, for example "fs.path(\"/etc/hosts\")".
	Expr string

	// Document is the buffer the expression appears in, so a host can consult
	// the file's own bindings.
	Document *Document

	// Resolve re-enters the server's inference for a sub-expression. A host
	// uses it to walk a chain without reimplementing the walk.
	Resolve func(expr string) (owner string, ok bool)
}

TypeRequest describes one type-inference question.

type TypeResolver

type TypeResolver interface {
	ResolveType(req TypeRequest) (owner string, ok bool)
}

A TypeResolver maps an expression to the owner whose members it exposes.

The server calls it with the receiver text left of the caret's dot, already trimmed: "fs", "fs.path(\"/etc\")", or a bare identifier the server has resolved to its binding expression. Returning false means the server offers no completion, which is deliberately better than offering the wrong list.

type Vanilla

type Vanilla struct {
	// contains filtered or unexported fields
}

Vanilla is the Host for Starlark as the specification defines it, with no dialect extensions.

Its name set is introspected rather than written down. The globals come from starlark.Universe, and the methods on strings, lists, dicts, sets, and bytes come from calling AttrNames on a zero value of each type. A build of go.starlark.net that adds a method therefore gains completion for it with no change here.

The documentation below is written by hand, because a Starlark builtin carries neither its parameter names nor its description. That is acceptable for exactly one reason: the Starlark specification is frozen. A hand-written table tracking a moving API rots; a hand-written table tracking a specification does not.

func NewVanilla

func NewVanilla() *Vanilla

NewVanilla returns the specification-only host.

func (*Vanilla) Globals

func (v *Vanilla) Globals() []Symbol

func (*Vanilla) Members

func (v *Vanilla) Members(owner string) []Symbol

func (*Vanilla) Name

func (v *Vanilla) Name() string

func (*Vanilla) ResolveType

func (v *Vanilla) ResolveType(req TypeRequest) (string, bool)

ResolveType infers the type of a literal receiver, so that completion works after a string, list, dict, or set written inline.

type VersionedTextDocumentIdentifier

type VersionedTextDocumentIdentifier struct {
	URI     string `json:"uri"`
	Version int    `json:"version"`
}

type WorkspaceEdit

type WorkspaceEdit struct {
	Changes map[string][]TextEdit `json:"changes"`
}

WorkspaceEdit carries a rename's replacements, keyed by document URI.

Directories

Path Synopsis
cmd
starlsp command
Command starlsp is a language server for Starlark.
Command starlsp is a language server for Starlark.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL