// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) // SPDX-License-Identifier: BSD-3-Clause package lsp import ( "cmp" "fmt" "os" "path/filepath" "regexp" "runtime" "slices" "strings" "unicode" "unicode/utf16" "unicode/utf8" "sourcedock.dev/petrbalvin/gasm-devkit/arch" "sourcedock.dev/petrbalvin/gasm-devkit/ast" "sourcedock.dev/petrbalvin/gasm-devkit/format" "sourcedock.dev/petrbalvin/gasm-devkit/lexer" "sourcedock.dev/petrbalvin/gasm-devkit/lint" "sourcedock.dev/petrbalvin/gasm-devkit/parser" "sourcedock.dev/petrbalvin/gasm-devkit/token" ) // textflagMacros are the flag names defined by runtime/textflag.h; they are // highlighted as macros and offered as completions after a TEXT/GLOBL // directive. var textflagMacros = map[string]bool{ "NOPROF": true, "DUPOK": true, "NOSPLIT": true, "RODATA": true, "NOPTR": true, "WRAPPER": true, "NEEDCTXT": true, "TLSBSS": true, "NOFRAME": true, "REFLECTMETHOD": true, "TOPFRAME": true, "ABIWRAPPER": 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"}) } } slices.SortFunc(items, func(a, b CompletionItem) int { return cmp.Compare(a.Label, b.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: clientRange(text, rng), } } // openAST is one open document with its parsed file. type openAST struct { uri string file *ast.File } // openASTs parses every open document, in URI order for deterministic // results. Parsing is tolerant: a buffer with syntax errors still // contributes its usable declarations to the workspace scans. func (s *Server) openASTs() []openAST { uris := make([]string, 0, len(s.docs)) for uri := range s.docs { uris = append(uris, uri) } slices.Sort(uris) out := make([]openAST, 0, len(uris)) for _, uri := range uris { if f, _ := parser.Parse(uriPath(uri), s.docs[uri]); f != nil { out = append(out, openAST{uri: uri, file: f}) } } return out } // definition returns the location of the named label or function: a local // label in the current document wins, then the TEXT functions of every open // document are searched, so a `CALL ·helper(SB)` jumps to its definition in // another file. func (s *Server) definition(p definitionParams) []Location { text := s.docs[p.TextDocument.URI] word, _ := wordAt(text, p.Position) if word == "" { return nil } name := strings.TrimPrefix(word, "\u00B7") // The local label definition. f, _ := parser.Parse(uriPath(p.TextDocument.URI), text) if f != nil { for _, d := range f.Decls { if t, ok := d.(*ast.Text); ok { for _, stmt := range t.Body { if lbl, ok := stmt.(*ast.Label); ok { if lbl.Name.Text == name || lbl.Name.Text == word { return []Location{{ URI: p.TextDocument.URI, Range: clientRange(text, tokenRange(lbl.Name)), }} } } } } } } // Function definitions across the open workspace. for _, of := range s.openASTs() { for _, d := range of.file.Decls { t, ok := d.(*ast.Text) if !ok || !realSymbol(t.Name) { continue } if t.Name.Name == name { return []Location{{URI: of.uri, Range: clientRange(s.docs[of.uri], symRange(t.Name))}} } } } return nil } // references returns all locations where the symbol under the cursor appears // across every open document. The current document matches labels and any // operand name, as before; other documents only match SB-qualified operand // references and the definition itself, because a bare name is a // function-local label whose repeats in other files are unrelated. func (s *Server) references(p referenceParams) []Location { text := s.docs[p.TextDocument.URI] word, _ := wordAt(text, p.Position) if word == "" { return nil } name := strings.TrimPrefix(word, "\u00B7") uri := p.TextDocument.URI var out []Location for _, of := range s.openASTs() { sameDoc := of.uri == uri for _, d := range of.file.Decls { t, ok := d.(*ast.Text) if !ok || !realSymbol(t.Name) { continue } // Include the definition if requested. if p.Context.IncludeDeclaration && t.Name.Name == name { out = append(out, Location{URI: of.uri, Range: clientRange(s.docs[of.uri], symRange(t.Name))}) } for _, stmt := range t.Body { switch st := stmt.(type) { case *ast.Label: if sameDoc && st.Name.Text == name { out = append(out, Location{URI: of.uri, Range: clientRange(s.docs[of.uri], tokenRange(st.Name))}) } case *ast.Instr: for _, op := range st.Operands { if op.Addr.Sym == nil || op.Addr.Sym.Name != name { continue } if !sameDoc && op.Addr.Sym.Pseudo != "SB" { continue } // The range covers the operand's verbatim // identifier, `·`/package prefix included, so a // rename replaces the whole spelling. out = append(out, Location{ URI: of.uri, Range: clientRange(s.docs[of.uri], Range{ Start: Position{Line: op.Pos.Line - 1, Character: op.Pos.Column - 1}, End: Position{Line: op.Pos.Line - 1, Character: op.Pos.Column - 1 + symIdentLen(op.Addr.Sym)}, }), }) } } } } } return out } // rename renames a label across every document the references reach. Each // edit is grouped under the URI it belongs to: a range collected in another // file must never be applied to the document that started the rename. func (s *Server) rename(p renameParams) *WorkspaceEdit { text := s.docs[p.TextDocument.URI] word, _ := wordAt(text, p.Position) if word == "" || word == p.NewName { return nil } refs := s.references(referenceParams{ TextDocument: p.TextDocument, Position: p.Position, Context: referenceContext{IncludeDeclaration: true}, }) if len(refs) == 0 { return nil } edits := make(map[string][]TextEdit, len(refs)) for _, loc := range refs { edits[loc.URI] = append(edits[loc.URI], TextEdit{Range: loc.Range, NewText: p.NewName}) } return &WorkspaceEdit{Changes: edits} } // documentFormatting returns a single edit that replaces the whole document // with its formatted version (canonical indentation and spacing). func (s *Server) documentFormatting(p documentFormattingParams) []TextEdit { text := s.docs[p.TextDocument.URI] if text == "" { return nil } formatted := format.Source(text) if formatted == text { return nil } lines := strings.Split(text, "\n") endLine := len(lines) - 1 endChar := 0 if endLine >= 0 { endChar = utf16Len(lines[endLine]) } return []TextEdit{{ Range: Range{Start: Position{Line: 0, Character: 0}, End: Position{Line: endLine, Character: endChar}}, NewText: formatted, }} } // inlayHints returns hints for frame sizes and argument areas. func (s *Server) inlayHints(p inlayHintParams) []InlayHint { text := s.docs[p.TextDocument.URI] f, _ := parser.Parse(uriPath(p.TextDocument.URI), text) if f == nil { return nil } var out []InlayHint for _, d := range f.Decls { t, ok := d.(*ast.Text) if !ok { continue } // Hint after the args size: show frame size. if t.Frame != nil && t.Frame.Imm.HasVal && t.Args != nil && t.Args.Imm.HasVal { // Place hint after the args operand using its raw text length. line := t.Args.Pos.Line - 1 col := t.Args.Pos.Column - 1 + runeLen(t.Args.Raw) out = append(out, InlayHint{ Position: Position{Line: line, Character: utf16Column(lineAt(text, line), col)}, Label: fmt.Sprintf(" frame=%d", t.Frame.Imm.Val), Kind: inlayHintTypeParameter, Tooltip: fmt.Sprintf("local frame size: %d bytes", t.Frame.Imm.Val), }) } } return out } // codeActions returns quick fixes for diagnostics in the selected range. func (s *Server) codeActions(p codeActionParams) []CodeAction { text := s.docs[p.TextDocument.URI] if text == "" { return nil } var actions []CodeAction for _, diag := range p.Context.Diagnostics { switch diag.Code { case "missing-ret": // Offer to add RET at the end of the flagged function only: the // diagnostic's range covers the TEXT keyword, so a line match // picks the function the diagnostic belongs to. f, _ := parser.Parse(uriPath(p.TextDocument.URI), text) if f == nil { continue } for _, d := range f.Decls { t, ok := d.(*ast.Text) if !ok { continue } if t.Keyword.Pos.Line-1 != int(diag.Range.Start.Line) { continue } if len(t.Body) == 0 { continue } last := t.Body[len(t.Body)-1] line := last.Pos().Line // Insert RET after the last statement. lines := strings.Split(text, "\n") if line-1 < len(lines) { insertLine := line // zero-based index for insertion after last stmt newLines := make([]string, 0, len(lines)+1) newLines = append(newLines, lines[:insertLine]...) newLines = append(newLines, "\tRET") newLines = append(newLines, lines[insertLine:]...) newText := strings.Join(newLines, "\n") endLine := len(lines) - 1 endChar := utf16Len(lines[endLine]) actions = append(actions, CodeAction{ Title: "Add RET to " + t.Name.Name, Kind: "quickfix", Edit: &WorkspaceEdit{ Changes: map[string][]TextEdit{p.TextDocument.URI: { {Range: Range{Start: Position{Line: 0, Character: 0}, End: Position{Line: endLine, Character: endChar}}, NewText: newText}, }}, }, }) } } case "unused-label": // Offer to remove the unused label. label := strings.TrimPrefix(diag.Message, "label \"") label = strings.TrimSuffix(label, "\" is defined but never referenced") if label != "" { lines := strings.Split(text, "\n") for i, line := range lines { trimmed := strings.TrimSpace(line) if trimmed == label+":" { newLines := make([]string, 0, len(lines)-1) newLines = append(newLines, lines[:i]...) newLines = append(newLines, lines[i+1:]...) newText := strings.Join(newLines, "\n") endLine := len(lines) - 1 endChar := utf16Len(lines[endLine]) actions = append(actions, CodeAction{ Title: "Remove unused label \"" + label + "\"", Kind: "quickfix", Edit: &WorkspaceEdit{ Changes: map[string][]TextEdit{p.TextDocument.URI: { {Range: Range{Start: Position{Line: 0, Character: 0}, End: Position{Line: endLine, Character: endChar}}, NewText: newText}, }}, }, }) break } } } } } return actions } // signatureHelp returns signature information for a CALL instruction. func (s *Server) signatureHelp(p signatureHelpParams) *SignatureHelp { text := s.docs[p.TextDocument.URI] if text == "" { return nil } // Check if the cursor is on a CALL/BL line. lines := strings.Split(text, "\n") if p.Position.Line >= len(lines) { return nil } line := lines[p.Position.Line] trimmed := strings.TrimSpace(line) upper := strings.ToUpper(trimmed) if !strings.HasPrefix(upper, "CALL ") && !strings.HasPrefix(upper, "BL ") { return nil } // Extract the function name from the CALL operand. parts := strings.Fields(trimmed) if len(parts) < 2 { return nil } funcName := parts[1] // Strip (SB) suffix. funcName = strings.TrimSuffix(funcName, "(SB)") // Look up the function in the document. f, _ := parser.Parse(uriPath(p.TextDocument.URI), text) if f == nil { return nil } // Strip middle-dot prefix if present (Plan 9 package separator). bareName := strings.TrimPrefix(funcName, "·") for _, d := range f.Decls { t, ok := d.(*ast.Text) if !ok { continue } if t.Name.Name == bareName || t.Name.Name == funcName || strings.HasSuffix(t.Name.Name, "·"+bareName) { label := "TEXT " + t.Name.Name + "(SB)" sig := SignatureInformation{ Label: label, } if t.Doc != "" { sig.Documentation = t.Doc } // Add frame and args as parameters. frame := int64(0) if t.Frame != nil && t.Frame.Imm.HasVal { frame = t.Frame.Imm.Val } args := int64(0) if t.Args != nil && t.Args.Imm.HasVal { args = t.Args.Imm.Val } sig.Parameters = []ParameterInformation{ {Label: fmt.Sprintf("$%d", frame)}, {Label: fmt.Sprintf("-%d", args)}, } return &SignatureHelp{Signatures: []SignatureInformation{sig}} } } return nil } // documentHighlights returns all occurrences of the symbol under the cursor. func (s *Server) documentHighlights(p documentHighlightParams) []DocumentHighlight { text := s.docs[p.TextDocument.URI] word, _ := wordAt(text, p.Position) if word == "" { return nil } // Symbol names are stored without the middle dot, while the word under // the cursor keeps it; trim once and compare the trimmed form, the way // references and definition do. name := strings.TrimPrefix(word, "\u00B7") f, errs := parser.Parse(uriPath(p.TextDocument.URI), text) if f == nil || len(errs) > 0 { return nil } var out []DocumentHighlight for _, d := range f.Decls { t, ok := d.(*ast.Text) if !ok { continue } // Highlight the definition. if t.Name != nil && t.Name.Name == name { out = append(out, DocumentHighlight{ Range: clientRange(text, symRange(t.Name)), Kind: highlightWrite, }) } for _, stmt := range t.Body { switch st := stmt.(type) { case *ast.Label: if st.Name.Text == name { out = append(out, DocumentHighlight{ Range: clientRange(text, tokenRange(st.Name)), Kind: highlightWrite, }) } case *ast.Instr: for _, op := range st.Operands { if op.Addr.Sym != nil && op.Addr.Sym.Name == name { out = append(out, DocumentHighlight{ Range: clientRange(text, Range{ Start: Position{Line: op.Pos.Line - 1, Character: op.Pos.Column - 1}, End: Position{Line: op.Pos.Line - 1, Character: op.Pos.Column - 1 + symIdentLen(op.Addr.Sym)}, }), Kind: highlightRead, }) } } } } } return out } // workspaceSymbols searches all open documents for symbols matching the query. func (s *Server) workspaceSymbols(p workspaceSymbolParams) []WorkspaceSymbol { if p.Query == "" { return nil } query := strings.ToLower(p.Query) var out []WorkspaceSymbol for uri, text := range s.docs { f, _ := parser.Parse(uriPath(uri), text) if f == nil { continue } for _, d := range f.Decls { switch dd := d.(type) { case *ast.Text: // A malformed TEXT line is kept in the tree under a "?" // placeholder; it is not a symbol, and skipping it keeps // the answer serving the rest of a mid-edit buffer. if !realSymbol(dd.Name) { continue } if strings.Contains(strings.ToLower(dd.Name.Name), query) { out = append(out, WorkspaceSymbol{ Name: dd.Name.Name, Kind: symFunction, Location: Location{URI: uri, Range: clientRange(text, symRange(dd.Name))}, }) } case *ast.Globl: if dd.Name != nil && strings.Contains(strings.ToLower(dd.Name.Name), query) { out = append(out, WorkspaceSymbol{ Name: dd.Name.Name, Kind: symConstant, Location: Location{URI: uri, Range: clientRange(text, symRange(dd.Name))}, }) } case *ast.Data: if dd.Name != nil && strings.Contains(strings.ToLower(dd.Name.Name), query) { out = append(out, WorkspaceSymbol{ Name: dd.Name.Name, Kind: symConstant, Location: Location{URI: uri, Range: clientRange(text, symRange(dd.Name))}, }) } } } } return out } // 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: // As in workspaceSymbols: a "?" placeholder is not a symbol, // and the remaining declarations are still listed. if !realSymbol(dd.Name) { continue } sym := DocumentSymbol{ Name: dd.Name.Name, Detail: "TEXT " + strings.Join(dd.Flags, " "), Kind: symFunction, Range: clientRange(text, textRange(dd)), SelectionRange: clientRange(text, 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: clientRange(text, tokenRange(l.Name)), SelectionRange: clientRange(text, tokenRange(l.Name)), }) } } out = append(out, sym) case *ast.Globl: if dd.Name == nil { continue } out = append(out, DocumentSymbol{ Name: dd.Name.Name, Detail: "GLOBL", Kind: symConstant, Range: clientRange(text, symRange(dd.Name)), SelectionRange: clientRange(text, symRange(dd.Name)), }) case *ast.Data: if dd.Name == nil { continue } out = append(out, DocumentSymbol{ Name: dd.Name.Name, Detail: "DATA", Kind: symConstant, Range: clientRange(text, symRange(dd.Name)), SelectionRange: clientRange(text, 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) srcLines := strings.Split(text, "\n") var encoded []semTok for _, line := range lines { for _, st := range classifyLine(line, a, labels) { // Columns cross the protocol boundary in UTF-16 code units. if st.line >= 0 && st.line < len(srcLines) { st.char = utf16Column(srcLines[st.line], st.char) } encoded = append(encoded, st) } } 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(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, token.Pipe: typ = stOperator } if typ >= 0 { out = append(out, semTok{ line: t.Pos.Line - 1, char: t.Pos.Column - 1, length: utf16Len(t.Text), typ: typ, }) } } return out } // classifyIdent decides the semantic type of an identifier token. func classifyIdent(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. The // incoming character offset is UTF-16 code units, as LSP defines it, and is // converted to the rune index the scanning works in. 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 := runeColumn(lines[pos.Line], 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)) } // utf16Len returns the length of s in UTF-16 code units, the unit LSP // positions count: an astral rune (an emoji in a comment) is two of them. func utf16Len(s string) int { n := 0 for _, r := range s { n += utf16.RuneLen(r) } return n } // utf16Column converts a rune-based column on line into a UTF-16 code-unit // offset. Rune columns are the lexer's convention; code units are the // protocol's, and the two diverge once an astral rune precedes the column. func utf16Column(line string, col int) int { if col <= 0 { return 0 } units := 0 seen := 0 for _, r := range line { if seen >= col { break } units += utf16.RuneLen(r) seen++ } return units } // runeColumn converts a UTF-16 code-unit offset on line into a rune column, // the inverse of utf16Column, applied to positions arriving from the client. func runeColumn(line string, units int) int { if units <= 0 { return 0 } col := 0 u := 0 for _, r := range line { if u >= units { break } u += utf16.RuneLen(r) col++ } return col } // lineAt returns the n-th zero-based line of text, or "" when out of range. func lineAt(text string, n int) string { if n < 0 { return "" } lines := strings.Split(text, "\n") if n >= len(lines) { return "" } return lines[n] } // clientRange re-encodes a rune-based range (the columns the lexer, parser // and the helpers above produce) in the UTF-16 code units LSP mandates. // Every range leaving the server passes through here. func clientRange(text string, r Range) Range { return Range{ Start: Position{Line: r.Start.Line, Character: utf16Column(lineAt(text, r.Start.Line), r.Start.Character)}, End: Position{Line: r.End.Line, Character: utf16Column(lineAt(text, r.End.Line), r.End.Character)}, } } // symIdentLen returns the rune length of a symbol's identifier as written: // the verbatim spelling up to the ABI marker, offset or pseudo-register // group, so `pkg·name(SB)` counts the package prefix and the // middle dot. A range built from it covers the whole token a rename // replaces; the stripped Name alone would stop one character short. func symIdentLen(sym *ast.Symbol) int { if i := strings.IndexAny(sym.Raw, "<+-("); i >= 0 { return runeLen(sym.Raw[:i]) } return runeLen(sym.Raw) } // symRange builds a range covering a symbol as written, prefix included. func symRange(sym *ast.Symbol) Range { start := Position{Line: sym.Pos.Line - 1, Character: sym.Pos.Column - 1} end := start end.Character += symIdentLen(sym) return Range{Start: start, End: end} } // realSymbol reports whether sym names something a client can act on. The // parser keeps a malformed TEXT in the tree under a "?" placeholder so the // rest of the buffer stays servable; that placeholder is not a symbol. func realSymbol(sym *ast.Symbol) bool { return sym != nil && sym.Name != "?" } // 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} } // diagnosticsFor computes the LSP diagnostics of one document; the push // (publishDiagnostics) and pull (textDocument/diagnostic) paths share it. // Parse errors surface as error-severity diagnostics so a malformed line is // visible in the editor instead of only breaking derived features. func (s *Server) diagnosticsFor(uri string) []Diagnostic { text := s.docs[uri] f, errs := parser.Parse(uriPath(uri), text) cfg := lint.Config{Arch: arch.FromFilename(uriPath(uri))} diags := lint.File(f, cfg) out := make([]Diagnostic, 0, len(diags)+len(errs)) for _, e := range errs { pos := token.Position{Line: 1, Column: 1} if pe, ok := e.(parser.Error); ok && pe.Pos.IsValid() { pos = pe.Pos } out = append(out, Diagnostic{ Range: clientRange(text, toRange(pos.Line, pos.Column, token.Position{})), Severity: sevError, Code: "syntax", Source: "gasm", Message: e.Error(), }) } for _, d := range diags { out = append(out, Diagnostic{ Range: clientRange(text, toRange(d.Pos.Line, d.Pos.Column, d.End)), Severity: lintSeverity(d.Severity), Code: d.Code, Source: "gasm", Message: d.Message, }) } return out } // includeRe matches a #include directive's quoted path. var includeRe = regexp.MustCompile(`^\s*#include\s+"([^"]+)"`) // documentLinks links each #include path to the header file it resolves to: // first relative to the document's directory, then in $GOROOT/pkg/include // (where textflag.h and friends live). Unresolvable paths get no link. func (s *Server) documentLinks(uri string) []DocumentLink { text := s.docs[uri] if text == "" { return nil } docDir := filepath.Dir(uriPath(uri)) goroot := runtime.GOROOT() var out []DocumentLink lineNo := -1 for line := range strings.SplitSeq(text, "\n") { lineNo++ m := includeRe.FindStringSubmatch(line) if m == nil { continue } target := resolveInclude(docDir, goroot, m[1]) if target == "" { continue } // strings.Index is a byte offset; columns are runes. start := utf8.RuneCountInString(line[:strings.Index(line, "\"")]) out = append(out, DocumentLink{ Range: clientRange(text, Range{ Start: Position{Line: lineNo, Character: start}, End: Position{Line: lineNo, Character: start + runeLen(m[1]) + 2}, }), Target: "file://" + target, }) } return out } // resolveInclude returns the existing file a #include path refers to, or "". func resolveInclude(docDir, goroot, path string) string { for _, dir := range []string{docDir, filepath.Join(goroot, "pkg", "include")} { cand := filepath.Join(dir, path) if _, err := os.Stat(cand); err == nil { return cand } } return "" } // foldingRanges marks each TEXT function's body as a collapsible region. func (s *Server) foldingRanges(uri string) []FoldingRange { text := s.docs[uri] f, _ := parser.Parse(uriPath(uri), text) if f == nil { return nil } var out []FoldingRange for _, d := range f.Decls { t, ok := d.(*ast.Text) if !ok || len(t.Body) == 0 { continue } start := t.Keyword.Pos.Line - 1 end := t.Body[len(t.Body)-1].Pos().Line - 1 if end <= start { continue } out = append(out, FoldingRange{StartLine: start, EndLine: end, Kind: "region"}) } return out }