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:
+382
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user