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