feat: gasm-devkit 0.1.0 — GAsm lexer, parser, linter, formatter, LSP and amd64 assembler

Assisted-by: Qwen 3.8 Max Preview
This commit is contained in:
2026-07-06 09:49:50 +02:00
commit d5a4a6de45
53 changed files with 13166 additions and 0 deletions
+382
View File
@@ -0,0 +1,382 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
package lsp
import (
"sort"
"strings"
"unicode"
"sourcedock.dev/petrbalvin/gasm-devkit/arch"
"sourcedock.dev/petrbalvin/gasm-devkit/ast"
"sourcedock.dev/petrbalvin/gasm-devkit/lexer"
"sourcedock.dev/petrbalvin/gasm-devkit/parser"
"sourcedock.dev/petrbalvin/gasm-devkit/token"
)
// textflagMacros are the flag names defined by textflag.h; they are highlighted
// as macros and offered as completions after a TEXT/GLOBL directive.
var textflagMacros = map[string]bool{
"NOPROFILE": true, "DUPOK": true, "NOSPLIT": true, "RODATA": true,
"NOPTR": true, "WRAPPER": true, "NEEDCTXT": true, "TOPFRAME": true,
"LEAF": true, "ABI0": true, "REFLECTDATA": true,
}
// completion builds the completion list for a document.
func (s *Server) completion(p completionParams) []CompletionItem {
a := arch.ForArch(arch.FromFilename(uriPath(p.TextDocument.URI)))
tab := a
items := []CompletionItem{
{Label: "TEXT", Kind: ciKeyword, Detail: "define a function"},
{Label: "DATA", Kind: ciKeyword, Detail: "initialise a data symbol"},
{Label: "GLOBL", Kind: ciKeyword, Detail: "declare a global symbol"},
}
for name := range textflagMacros {
items = append(items, CompletionItem{Label: name, Kind: ciKeyword, Detail: "textflag.h flag"})
}
for name, desc := range map[string]string{
"FP": "frame pointer (arguments/results)", "SP": "stack pointer",
"SB": "static base (globals)", "PC": "program counter",
} {
items = append(items, CompletionItem{Label: name, Kind: ciConstant, Detail: desc})
}
for _, in := range tab.Instructions() {
items = append(items, CompletionItem{
Label: in.Name, Kind: ciFunction, Detail: in.Summary, Documentation: in.Summary,
})
}
for _, r := range tab.Registers() {
kind := ciVariable
if r.Class == arch.Vector || r.Class == arch.Mask || r.Class == arch.Float || r.Class == arch.VecARM {
kind = ciClass
}
items = append(items, CompletionItem{Label: r.Name, Kind: kind, Detail: r.Desc})
}
// Local labels defined in the document.
if f, _ := parser.Parse("", s.docs[p.TextDocument.URI]); f != nil {
for _, name := range labelNames(f) {
items = append(items, CompletionItem{Label: name, Kind: ciModule, Detail: "local label"})
}
}
sort.Slice(items, func(i, j int) bool { return items[i].Label < items[j].Label })
return items
}
// hover returns documentation for the symbol under the cursor.
func (s *Server) hover(p hoverParams) *Hover {
text := s.docs[p.TextDocument.URI]
word, rng := wordAt(text, p.Position)
if word == "" {
return nil
}
a := arch.ForArch(arch.FromFilename(uriPath(p.TextDocument.URI)))
var md string
if in, ok := a.Lookup(word); ok {
md = "**" + in.Name + "** — " + in.Summary
} else if r, ok := a.Register(word); ok {
md = "**" + r.Name + "** — " + r.Class.String() + " register. " + r.Desc
} else if desc, ok := arch.PseudoRegDesc(word); ok {
md = "**" + strings.ToUpper(word) + "** — pseudo-register. " + desc
} else if textflagMacros[strings.ToUpper(word)] {
md = "**" + strings.ToUpper(word) + "** — textflag.h flag"
} else {
return nil
}
return &Hover{
Contents: markupContent{Kind: "markdown", Value: md},
Range: rng,
}
}
// documentSymbols returns functions and their labels, plus global symbols.
func (s *Server) documentSymbols(p documentSymbolParams) []DocumentSymbol {
text := s.docs[p.TextDocument.URI]
f, _ := parser.Parse(uriPath(p.TextDocument.URI), text)
if f == nil {
return nil
}
var out []DocumentSymbol
for _, d := range f.Decls {
switch dd := d.(type) {
case *ast.Text:
sym := DocumentSymbol{
Name: dd.Name.Name,
Detail: "TEXT " + strings.Join(dd.Flags, " "),
Kind: symFunction,
Range: textRange(dd),
SelectionRange: symRange(dd.Name),
}
for _, st := range dd.Body {
if l, ok := st.(*ast.Label); ok {
sym.Children = append(sym.Children, DocumentSymbol{
Name: l.Name.Text,
Kind: symVariable,
Range: tokenRange(l.Name),
SelectionRange: tokenRange(l.Name),
})
}
}
out = append(out, sym)
case *ast.Globl:
out = append(out, DocumentSymbol{
Name: dd.Name.Name, Detail: "GLOBL", Kind: symConstant,
Range: symRange(dd.Name), SelectionRange: symRange(dd.Name),
})
case *ast.Data:
out = append(out, DocumentSymbol{
Name: dd.Name.Name, Detail: "DATA", Kind: symConstant,
Range: symRange(dd.Name), SelectionRange: symRange(dd.Name),
})
}
}
return out
}
// semTok is one classified token before delta encoding.
type semTok struct {
line, char, length, typ int
}
// semanticTokens encodes syntax highlighting as LSP semantic tokens.
func (s *Server) semanticTokens(p semanticTokensParams) SemanticTokens {
text := s.docs[p.TextDocument.URI]
a := arch.ForArch(arch.FromFilename(uriPath(p.TextDocument.URI)))
f, _ := parser.Parse("", text)
labels := map[string]bool{}
for _, name := range labelNames(f) {
labels[name] = true
}
toks := lexer.Tokenize(text)
lines := groupLines(toks)
var encoded []semTok
for _, line := range lines {
encoded = append(encoded, classifyLine(line, a, labels)...)
}
return SemanticTokens{Data: deltaEncode(encoded)}
}
// classifyLine assigns a semantic token type to each significant token on a line.
func classifyLine(line []token.Token, a *arch.Table, labels map[string]bool) []semTok {
if len(line) == 0 {
return nil
}
var out []semTok
first := firstSignificant(line)
if first < 0 {
return nil
}
isDirective := line[first].Kind == token.Ident &&
(line[first].Text == "TEXT" || line[first].Text == "DATA" || line[first].Text == "GLOBL")
isLabel := line[first].Kind == token.Ident && first+1 < len(line) &&
line[first+1].Kind == token.Colon
isInstr := !isDirective && !isLabel && line[first].Kind == token.Ident
mnemonicDone := false
for i, t := range line {
typ := -1
switch t.Kind {
case token.Comment:
typ = stComment
case token.Number:
typ = stNumber
case token.String, token.Rune:
typ = stString
case token.Hash:
typ = stMacro
case token.Ident:
typ = classifyIdent(line, i, first, t.Text, a, labels,
isDirective, isLabel, isInstr, &mnemonicDone)
case token.Colon, token.Comma, token.LParen, token.RParen,
token.Plus, token.Minus, token.Star, token.Slash, token.Dollar,
token.LAngle, token.RAngle, token.LShift, token.RShift, token.Arrow, token.At:
typ = stOperator
}
if typ >= 0 {
out = append(out, semTok{
line: t.Pos.Line - 1,
char: t.Pos.Column - 1,
length: runeLen(t.Text),
typ: typ,
})
}
}
return out
}
// classifyIdent decides the semantic type of an identifier token.
func classifyIdent(line []token.Token, i, first int, text string, a *arch.Table,
labels map[string]bool, isDirective, isLabel, isInstr bool, mnemonicDone *bool) int {
upper := strings.ToUpper(text)
switch {
case isDirective && i == first:
return stKeyword
case isDirective && textflagMacros[upper]:
return stMacro
case isLabel && i == first:
return stNamespace
case arch.IsPseudoReg(text):
return stProperty
case labels[text]:
return stNamespace
}
if r, ok := a.Register(text); ok {
switch r.Class {
case arch.Vector, arch.Mask, arch.Float, arch.VecARM:
return stType
default:
return stVariable
}
}
if isInstr && i == first && !*mnemonicDone {
*mnemonicDone = true
return stFunction
}
// Argument/symbol names and anything else.
return stVariable
}
// deltaEncode converts absolute token positions to the LSP relative encoding.
func deltaEncode(toks []semTok) []int {
data := make([]int, 0, len(toks)*5)
prevLine, prevChar := 0, 0
for _, t := range toks {
dLine := t.line - prevLine
dChar := t.char
if dLine == 0 {
dChar = t.char - prevChar
}
data = append(data, dLine, dChar, t.length, t.typ, 0)
prevLine, prevChar = t.line, t.char
}
return data
}
// --- shared helpers ---------------------------------------------------------
// wordAt extracts the identifier surrounding pos and its range.
func wordAt(text string, pos Position) (string, Range) {
lines := strings.Split(text, "\n")
if pos.Line < 0 || pos.Line >= len(lines) {
return "", Range{}
}
runes := []rune(lines[pos.Line])
col := pos.Character
if col < 0 || col > len(runes) {
return "", Range{}
}
isWord := func(r rune) bool {
return r == '_' || r == '\u00B7' || unicode.IsLetter(r) || unicode.IsDigit(r)
}
start, end := col, col
for start > 0 && isWord(runes[start-1]) {
start--
}
for end < len(runes) && isWord(runes[end]) {
end++
}
if start == end {
return "", Range{}
}
rng := Range{
Start: Position{Line: pos.Line, Character: start},
End: Position{Line: pos.Line, Character: end},
}
return string(runes[start:end]), rng
}
// labelNames collects every label defined in a file.
func labelNames(f *ast.File) []string {
if f == nil {
return nil
}
seen := map[string]bool{}
var out []string
collect := func(body []ast.Stmt) {
for _, st := range body {
if l, ok := st.(*ast.Label); ok && !seen[l.Name.Text] {
seen[l.Name.Text] = true
out = append(out, l.Name.Text)
}
}
}
for _, d := range f.Decls {
if t, ok := d.(*ast.Text); ok {
collect(t.Body)
}
}
collect(f.Orphans)
return out
}
// groupLines splits a token stream into lines, keeping Newline boundaries but
// dropping the Newline and EOF tokens themselves.
func groupLines(toks []token.Token) [][]token.Token {
var lines [][]token.Token
var cur []token.Token
for _, t := range toks {
if t.Kind == token.EOF {
break
}
if t.Kind == token.Newline {
lines = append(lines, cur)
cur = nil
continue
}
cur = append(cur, t)
}
if len(cur) > 0 {
lines = append(lines, cur)
}
return lines
}
func firstSignificant(line []token.Token) int {
for i, t := range line {
if t.Kind != token.Comment {
return i
}
}
return -1
}
func runeLen(s string) int { return len([]rune(s)) }
// symRange builds a range covering a symbol from its position and raw text.
func symRange(sym *ast.Symbol) Range {
start := Position{Line: sym.Pos.Line - 1, Character: sym.Pos.Column - 1}
end := start
end.Character += runeLen(sym.Name)
return Range{Start: start, End: end}
}
// tokenRange builds a range covering one token.
func tokenRange(t token.Token) Range {
return Range{
Start: Position{Line: t.Pos.Line - 1, Character: t.Pos.Column - 1},
End: Position{Line: t.End.Line - 1, Character: t.End.Column - 1},
}
}
// textRange spans a TEXT function from its keyword to the end of its body.
func textRange(t *ast.Text) Range {
start := Position{Line: t.Keyword.Pos.Line - 1, Character: t.Keyword.Pos.Column - 1}
end := start
end.Character += runeLen(t.Keyword.Text)
if n := len(t.Body); n > 0 {
last := t.Body[n-1]
if in, ok := last.(*ast.Instr); ok {
end = Position{Line: in.Mnemonic.End.Line - 1, Character: in.Mnemonic.End.Column - 1}
} else {
lp := last.Pos()
end = Position{Line: lp.Line - 1, Character: lp.Column - 1}
}
}
return Range{Start: start, End: end}
}
+255
View File
@@ -0,0 +1,255 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
// Package lsp implements a Language Server Protocol server for GAsm. It
// speaks JSON-RPC 2.0 over any io.Reader/io.Writer pair (normally standard
// input/output) and provides completion, hover documentation, document
// symbols, diagnostics and semantic-token highlighting — all backed by the
// pure-Go lexer, parser, arch and lint packages. It is the vendor-neutral
// integration point: any LSP-capable editor can use it with no editor-specific
// plugin code.
package lsp
import "encoding/json"
// --- JSON-RPC 2.0 -----------------------------------------------------------
// rpcMessage is the common envelope for every JSON-RPC message.
type rpcMessage struct {
JSONRPC string `json:"jsonrpc"`
ID *json.RawMessage `json:"id,omitempty"`
Method string `json:"method,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Result any `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
// JSON-RPC error codes used by LSP.
const (
errParse = -32700
errInvalidRequest = -32600
errMethodNotFound = -32601
errInvalidParams = -32602
errInternal = -32603
)
// --- LSP positions and ranges ----------------------------------------------
// Position is a zero-based line/character position, as LSP requires.
type Position struct {
Line int `json:"line"`
Character int `json:"character"`
}
// Range is a pair of positions.
type Range struct {
Start Position `json:"start"`
End Position `json:"end"`
}
// Location is a range within a document URI.
type Location struct {
URI string `json:"uri"`
Range Range `json:"range"`
}
// --- diagnostics ------------------------------------------------------------
// Diagnostic severities (LSP ordering: 1 = error).
const (
sevError = 1
sevWarning = 2
sevInformation = 3
sevHint = 4
)
// Diagnostic is one published finding.
type Diagnostic struct {
Range Range `json:"range"`
Severity int `json:"severity"`
Code string `json:"code,omitempty"`
Source string `json:"source,omitempty"`
Message string `json:"message"`
}
type publishDiagnosticsParams struct {
URI string `json:"uri"`
Diagnostics []Diagnostic `json:"diagnostics"`
}
// --- text document synchronisation -----------------------------------------
type textDocumentItem struct {
URI string `json:"uri"`
LanguageID string `json:"languageId"`
Version int `json:"version"`
Text string `json:"text"`
}
type didOpenParams struct {
TextDocument textDocumentItem `json:"textDocument"`
}
type versionedTextDocumentIdentifier struct {
URI string `json:"uri"`
Version int `json:"version"`
}
type textDocumentIdentifier struct {
URI string `json:"uri"`
}
type contentChangeEvent struct {
Text string `json:"text"`
}
type didChangeParams struct {
TextDocument versionedTextDocumentIdentifier `json:"textDocument"`
ContentChanges []contentChangeEvent `json:"contentChanges"`
}
type didCloseParams struct {
TextDocument textDocumentIdentifier `json:"textDocument"`
}
// --- completion -------------------------------------------------------------
// Completion item kinds (a useful subset).
const (
ciFunction = 3
ciField = 5
ciVariable = 6
ciClass = 7
ciModule = 9
ciKeyword = 14
ciConstant = 21
ciStruct = 22
)
// CompletionItem is one completion suggestion.
type CompletionItem struct {
Label string `json:"label"`
Kind int `json:"kind,omitempty"`
Detail string `json:"detail,omitempty"`
Documentation string `json:"documentation,omitempty"`
InsertText string `json:"insertText,omitempty"`
}
type completionParams struct {
TextDocument textDocumentIdentifier `json:"textDocument"`
Position Position `json:"position"`
}
// --- hover ------------------------------------------------------------------
type hoverParams struct {
TextDocument textDocumentIdentifier `json:"textDocument"`
Position Position `json:"position"`
}
// Hover is the hover response.
type Hover struct {
Contents markupContent `json:"contents"`
Range Range `json:"range,omitempty"`
}
type markupContent struct {
Kind string `json:"kind"`
Value string `json:"value"`
}
// --- document symbols -------------------------------------------------------
// Symbol kinds (a useful subset).
const (
symFunction = 12
symConstant = 14
symVariable = 13
)
// DocumentSymbol is a hierarchical symbol.
type DocumentSymbol struct {
Name string `json:"name"`
Detail string `json:"detail,omitempty"`
Kind int `json:"kind"`
Range Range `json:"range"`
SelectionRange Range `json:"selectionRange"`
Children []DocumentSymbol `json:"children,omitempty"`
}
type documentSymbolParams struct {
TextDocument textDocumentIdentifier `json:"textDocument"`
}
// --- semantic tokens --------------------------------------------------------
// semanticTokenTypes is the legend of token type names, in index order. The
// indices are referenced by the encoder below.
var semanticTokenTypes = []string{
"comment", // 0
"keyword", // 1
"function", // 2
"variable", // 3
"type", // 4
"number", // 5
"string", // 6
"operator", // 7
"property", // 8
"namespace", // 9
"macro", // 10
}
const (
stComment = 0
stKeyword = 1
stFunction = 2
stVariable = 3
stType = 4
stNumber = 5
stString = 6
stOperator = 7
stProperty = 8
stNamespace = 9
stMacro = 10
)
// SemanticTokensLegend advertises the token classification.
type SemanticTokensLegend struct {
TokenTypes []string `json:"tokenTypes"`
TokenModifiers []string `json:"tokenModifiers"`
}
// SemanticTokens is the encoded token payload.
type SemanticTokens struct {
Data []int `json:"data"`
}
type semanticTokensParams struct {
TextDocument textDocumentIdentifier `json:"textDocument"`
}
// --- initialize -------------------------------------------------------------
type initializeParams struct {
RootURI string `json:"rootUri"`
}
// ServerCapabilities advertises what this server provides.
type ServerCapabilities struct {
TextDocumentSync int `json:"textDocumentSync"`
CompletionProvider map[string]any `json:"completionProvider,omitempty"`
HoverProvider bool `json:"hoverProvider,omitempty"`
DocumentSymbolProvider bool `json:"documentSymbolProvider,omitempty"`
SemanticTokensProvider map[string]any `json:"semanticTokensProvider,omitempty"`
DiagnosticProvider map[string]any `json:"diagnosticProvider,omitempty"`
}
type initializeResult struct {
Capabilities ServerCapabilities `json:"capabilities"`
ServerInfo map[string]string `json:"serverInfo,omitempty"`
}
+249
View File
@@ -0,0 +1,249 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
package lsp
import (
"bufio"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"sync"
"sourcedock.dev/petrbalvin/gasm-devkit/arch"
"sourcedock.dev/petrbalvin/gasm-devkit/lint"
"sourcedock.dev/petrbalvin/gasm-devkit/parser"
"sourcedock.dev/petrbalvin/gasm-devkit/token"
)
// Server is a GAsm language server bound to a byte stream.
type Server struct {
in *bufio.Reader
out io.Writer
mu sync.Mutex // guards writes to out
docs map[string]string
}
// New returns a server reading from in and writing to out.
func New(in io.Reader, out io.Writer) *Server {
return &Server{
in: bufio.NewReader(in),
out: out,
docs: make(map[string]string),
}
}
// Run serves requests until the input is exhausted or an exit is requested.
func (s *Server) Run() error {
for {
msg, err := s.read()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
if exit := s.dispatch(msg); exit {
return nil
}
}
}
// read parses one Content-Length framed JSON-RPC message.
func (s *Server) read() (*rpcMessage, error) {
length := -1
for {
line, err := s.in.ReadString('\n')
if err != nil {
return nil, err
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
break
}
if k, v, ok := strings.Cut(line, ":"); ok && strings.EqualFold(strings.TrimSpace(k), "Content-Length") {
length, _ = strconv.Atoi(strings.TrimSpace(v))
}
}
if length < 0 {
return nil, fmt.Errorf("missing Content-Length header")
}
body := make([]byte, length)
if _, err := io.ReadFull(s.in, body); err != nil {
return nil, err
}
var msg rpcMessage
if err := json.Unmarshal(body, &msg); err != nil {
return nil, err
}
return &msg, nil
}
// send marshals and writes one framed message.
func (s *Server) send(msg *rpcMessage) error {
msg.JSONRPC = "2.0"
body, err := json.Marshal(msg)
if err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
if _, err := fmt.Fprintf(s.out, "Content-Length: %d\r\n\r\n", len(body)); err != nil {
return err
}
_, err = s.out.Write(body)
return err
}
func (s *Server) respond(id *json.RawMessage, result any) {
_ = s.send(&rpcMessage{ID: id, Result: result})
}
func (s *Server) respondError(id *json.RawMessage, code int, msg string) {
_ = s.send(&rpcMessage{ID: id, Error: &rpcError{Code: code, Message: msg}})
}
func (s *Server) notify(method string, params any) {
raw, _ := json.Marshal(params)
_ = s.send(&rpcMessage{Method: method, Params: raw})
}
// dispatch routes one message. It returns true when the server should stop.
func (s *Server) dispatch(msg *rpcMessage) (exit bool) {
switch msg.Method {
case "initialize":
s.respond(msg.ID, initializeResult{
Capabilities: ServerCapabilities{
TextDocumentSync: 1, // full sync
CompletionProvider: map[string]any{},
HoverProvider: true,
DocumentSymbolProvider: true,
SemanticTokensProvider: map[string]any{
"legend": SemanticTokensLegend{
TokenTypes: semanticTokenTypes,
TokenModifiers: []string{},
},
"full": true,
},
},
ServerInfo: map[string]string{"name": "gasm", "version": "0.1.0"},
})
case "initialized", "textDocument/didSave":
// Notifications with nothing to do.
case "textDocument/didOpen":
var p didOpenParams
if json.Unmarshal(msg.Params, &p) == nil {
s.docs[p.TextDocument.URI] = p.TextDocument.Text
s.publish(p.TextDocument.URI)
}
case "textDocument/didChange":
var p didChangeParams
if json.Unmarshal(msg.Params, &p) == nil && len(p.ContentChanges) > 0 {
// Full sync: the last change carries the whole document.
text := p.ContentChanges[len(p.ContentChanges)-1].Text
s.docs[p.TextDocument.URI] = text
s.publish(p.TextDocument.URI)
}
case "textDocument/didClose":
var p didCloseParams
if json.Unmarshal(msg.Params, &p) == nil {
delete(s.docs, p.TextDocument.URI)
// Clear diagnostics for the closed document.
s.notify("textDocument/publishDiagnostics", publishDiagnosticsParams{
URI: p.TextDocument.URI, Diagnostics: []Diagnostic{},
})
}
case "textDocument/completion":
var p completionParams
json.Unmarshal(msg.Params, &p)
s.respond(msg.ID, s.completion(p))
case "textDocument/hover":
var p hoverParams
json.Unmarshal(msg.Params, &p)
s.respond(msg.ID, s.hover(p))
case "textDocument/documentSymbol":
var p documentSymbolParams
json.Unmarshal(msg.Params, &p)
s.respond(msg.ID, s.documentSymbols(p))
case "textDocument/semanticTokens/full":
var p semanticTokensParams
json.Unmarshal(msg.Params, &p)
s.respond(msg.ID, s.semanticTokens(p))
case "shutdown":
s.respond(msg.ID, nil)
case "exit":
return true
default:
if msg.ID != nil {
s.respondError(msg.ID, errMethodNotFound, "method not supported: "+msg.Method)
}
}
return false
}
// publish parses and lints a document and pushes the diagnostics to the client.
func (s *Server) publish(uri string) {
text := s.docs[uri]
f, _ := parser.Parse(uri, text)
cfg := lint.Config{Arch: arch.FromFilename(uriPath(uri))}
diags := lint.File(f, cfg)
out := make([]Diagnostic, 0, len(diags))
for _, d := range diags {
out = append(out, Diagnostic{
Range: toRange(d.Pos.Line, d.Pos.Column, d.End),
Severity: lintSeverity(d.Severity),
Code: d.Code,
Source: "gasm",
Message: d.Message,
})
}
s.notify("textDocument/publishDiagnostics", publishDiagnosticsParams{URI: uri, Diagnostics: out})
}
// toRange converts one-based line/column plus an optional end position into an
// LSP range (zero-based).
func toRange(line, col int, end token.Position) Range {
start := Position{Line: line - 1, Character: col - 1}
finish := start
if end.IsValid() {
finish = Position{Line: end.Line - 1, Character: end.Column - 1}
} else {
finish.Character = start.Character + 1
}
return Range{Start: start, End: finish}
}
func lintSeverity(s lint.Severity) int {
switch s {
case lint.Error:
return sevError
case lint.Warning:
return sevWarning
case lint.Information:
return sevInformation
default:
return sevHint
}
}
// uriPath strips a file:// scheme and returns the path component.
func uriPath(uri string) string {
if rest, ok := strings.CutPrefix(uri, "file://"); ok {
return rest
}
return uri
}
+294
View File
@@ -0,0 +1,294 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
package lsp
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"testing"
)
const cleanDoc = "#include \"textflag.h\"\n" +
"TEXT ·foo(SB), NOSPLIT, $0\n" +
"\tMOVQ AX, CX\n" +
"loop:\n" +
"\tJMP loop\n" +
"\tRET\n"
const badDoc = "#include \"textflag.h\"\n" +
"TEXT ·foo(SB), NOSPLIT, $0\n" +
"\tNOSUCHINSTR AX, BX\n" +
"\tJMP missing\n"
// frame renders one Content-Length framed JSON-RPC message.
func frame(id any, method string, params any) string {
msg := map[string]any{"jsonrpc": "2.0"}
if id != nil {
msg["id"] = id
}
if method != "" {
msg["method"] = method
}
if params != nil {
msg["params"] = params
}
body, _ := json.Marshal(msg)
return fmt.Sprintf("Content-Length: %d\r\n\r\n%s", len(body), body)
}
// run feeds input to a server and returns every parsed output message.
func run(t *testing.T, input string) []rpcMessage {
t.Helper()
var out bytes.Buffer
srv := New(strings.NewReader(input), &out)
if err := srv.Run(); err != nil {
t.Fatalf("server run: %v", err)
}
return readFrames(t, &out)
}
// readFrames parses all framed messages from a buffer.
func readFrames(t *testing.T, r io.Reader) []rpcMessage {
t.Helper()
br := bufio.NewReader(r)
var msgs []rpcMessage
for {
length := -1
for {
line, err := br.ReadString('\n')
if err == io.EOF {
return msgs
}
if err != nil {
t.Fatalf("read header: %v", err)
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
break
}
if k, v, ok := strings.Cut(line, ":"); ok && strings.EqualFold(strings.TrimSpace(k), "Content-Length") {
length, _ = strconv.Atoi(strings.TrimSpace(v))
}
}
if length < 0 {
return msgs
}
body := make([]byte, length)
if _, err := io.ReadFull(br, body); err != nil {
t.Fatalf("read body: %v", err)
}
var m rpcMessage
if err := json.Unmarshal(body, &m); err != nil {
t.Fatalf("unmarshal: %v", err)
}
msgs = append(msgs, m)
}
}
// session builds a standard scripting of messages around a document.
func session(uri, text string) string {
var b strings.Builder
b.WriteString(frame(1, "initialize", map[string]any{"rootUri": ""}))
b.WriteString(frame(nil, "initialized", map[string]any{}))
b.WriteString(frame(nil, "textDocument/didOpen", map[string]any{
"textDocument": map[string]any{
"uri": uri, "languageId": "gasm", "version": 1, "text": text,
},
}))
return b.String()
}
func findByID(msgs []rpcMessage, n int) *rpcMessage {
for i := range msgs {
if msgs[i].ID != nil {
var id int
if json.Unmarshal(*msgs[i].ID, &id) == nil && id == n {
return &msgs[i]
}
}
}
return nil
}
func findMethod(msgs []rpcMessage, method string) *rpcMessage {
for i := range msgs {
if msgs[i].Method == method {
return &msgs[i]
}
}
return nil
}
func TestInitialize(t *testing.T) {
msgs := run(t, session("file:///f_amd64.s", cleanDoc)+frame(nil, "exit", nil))
resp := findByID(msgs, 1)
if resp == nil {
t.Fatal("no initialize response")
}
var res initializeResult
if err := json.Unmarshal(mustResult(t, resp), &res); err != nil {
t.Fatal(err)
}
if !res.Capabilities.HoverProvider || res.Capabilities.SemanticTokensProvider == nil {
t.Fatalf("unexpected capabilities: %+v", res.Capabilities)
}
}
func TestDiagnosticsClean(t *testing.T) {
msgs := run(t, session("file:///f_amd64.s", cleanDoc)+frame(nil, "exit", nil))
pub := findMethod(msgs, "textDocument/publishDiagnostics")
if pub == nil {
t.Fatal("no publishDiagnostics notification")
}
var p publishDiagnosticsParams
json.Unmarshal(pub.Params, &p)
if len(p.Diagnostics) != 0 {
t.Fatalf("clean doc should have no diagnostics, got %+v", p.Diagnostics)
}
}
func TestDiagnosticsErrors(t *testing.T) {
msgs := run(t, session("file:///f_amd64.s", badDoc)+frame(nil, "exit", nil))
pub := findMethod(msgs, "textDocument/publishDiagnostics")
if pub == nil {
t.Fatal("no publishDiagnostics notification")
}
var p publishDiagnosticsParams
json.Unmarshal(pub.Params, &p)
codes := map[string]bool{}
for _, d := range p.Diagnostics {
codes[d.Code] = true
}
if !codes["unknown-instruction"] || !codes["undefined-label"] {
t.Fatalf("expected unknown-instruction and undefined-label, got %+v", p.Diagnostics)
}
}
func TestCompletion(t *testing.T) {
in := session("file:///f_amd64.s", cleanDoc) +
frame(2, "textDocument/completion", map[string]any{
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
"position": map[string]any{"line": 2, "character": 1},
}) + frame(nil, "exit", nil)
msgs := run(t, in)
resp := findByID(msgs, 2)
if resp == nil {
t.Fatal("no completion response")
}
var items []CompletionItem
if err := json.Unmarshal(mustResult(t, resp), &items); err != nil {
t.Fatal(err)
}
labels := map[string]bool{}
for _, it := range items {
labels[it.Label] = true
}
for _, want := range []string{"MOVQ", "AX", "TEXT", "NOSPLIT", "loop"} {
if !labels[want] {
t.Errorf("completion missing %q", want)
}
}
}
func TestHover(t *testing.T) {
in := session("file:///f_amd64.s", cleanDoc) +
frame(3, "textDocument/hover", map[string]any{
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
"position": map[string]any{"line": 2, "character": 2}, // on MOVQ
}) + frame(nil, "exit", nil)
msgs := run(t, in)
resp := findByID(msgs, 3)
if resp == nil {
t.Fatal("no hover response")
}
var h Hover
if err := json.Unmarshal(mustResult(t, resp), &h); err != nil {
t.Fatal(err)
}
if !strings.Contains(h.Contents.Value, "MOVQ") {
t.Fatalf("hover = %q, want MOVQ docs", h.Contents.Value)
}
}
func TestDocumentSymbols(t *testing.T) {
in := session("file:///f_amd64.s", cleanDoc) +
frame(4, "textDocument/documentSymbol", map[string]any{
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
}) + frame(nil, "exit", nil)
msgs := run(t, in)
resp := findByID(msgs, 4)
if resp == nil {
t.Fatal("no documentSymbol response")
}
var syms []DocumentSymbol
if err := json.Unmarshal(mustResult(t, resp), &syms); err != nil {
t.Fatal(err)
}
if len(syms) == 0 || syms[0].Name != "foo" {
t.Fatalf("symbols = %+v, want function foo", syms)
}
found := false
for _, c := range syms[0].Children {
if c.Name == "loop" {
found = true
}
}
if !found {
t.Errorf("function foo should contain label loop: %+v", syms[0].Children)
}
}
func TestSemanticTokens(t *testing.T) {
in := session("file:///f_amd64.s", cleanDoc) +
frame(5, "textDocument/semanticTokens/full", map[string]any{
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
}) + frame(nil, "exit", nil)
msgs := run(t, in)
resp := findByID(msgs, 5)
if resp == nil {
t.Fatal("no semanticTokens response")
}
var st SemanticTokens
if err := json.Unmarshal(mustResult(t, resp), &st); err != nil {
t.Fatal(err)
}
if len(st.Data) == 0 || len(st.Data)%5 != 0 {
t.Fatalf("semantic tokens data invalid: len=%d", len(st.Data))
}
// There must be at least one "function" (mnemonic) and one "comment"-free
// keyword token; sanity-check that a MOVQ-classified function token exists.
seenFunction := false
for i := 3; i < len(st.Data); i += 5 {
if st.Data[i] == stFunction {
seenFunction = true
}
}
if !seenFunction {
t.Error("expected at least one function (mnemonic) semantic token")
}
}
func TestMethodNotFound(t *testing.T) {
in := frame(9, "bogus/method", map[string]any{}) + frame(nil, "exit", nil)
msgs := run(t, in)
resp := findByID(msgs, 9)
if resp == nil || resp.Error == nil || resp.Error.Code != errMethodNotFound {
t.Fatalf("expected method-not-found error, got %+v", resp)
}
}
// mustResult re-marshals a response result into raw JSON for typed decoding.
func mustResult(t *testing.T, m *rpcMessage) []byte {
t.Helper()
b, err := json.Marshal(m.Result)
if err != nil {
t.Fatal(err)
}
return b
}