Skip to content

Commit 9ef0c79

Browse files
authored
feat: support documentation on hover (#421)
* chore: goimports * feat: support hover on elements * fix: fix imports * fix: fix imports
1 parent 4acfad3 commit 9ef0c79

File tree

18 files changed

+389
-20
lines changed

18 files changed

+389
-20
lines changed

internal/server/versions.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import (
55
_ "embed"
66
"errors"
77
"fmt"
8-
"golang.org/x/sync/errgroup"
98
"runtime"
109
"strings"
1110
"time"
@@ -14,6 +13,7 @@ import (
1413
"github.com/x1unix/go-playground/internal/util/syncx"
1514
"github.com/x1unix/go-playground/pkg/goplay"
1615
"go.uber.org/zap"
16+
"golang.org/x/sync/errgroup"
1717
)
1818

1919
const (

web/src/components/features/workspace/CodeEditor/autocomplete/cache.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type * as monaco from 'monaco-editor'
22
import type { ImportsContext } from '~/services/completion'
3-
import { buildImportContext } from './parse'
3+
import { buildImportContext } from './parser/imports'
44

55
const stripSlash = (str: string) => (str[0] === '/' ? str.slice(1) : str)
66

Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './provider'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import * as monaco from 'monaco-editor'
2+
import { GoToken, isKeywordValueToken, tokenByOffset, tokenToString } from '../parser/tokens'
3+
4+
const getLineTokens = (str: string, lang: string): monaco.Token[] | null => {
5+
try {
6+
const tokens = monaco.editor.tokenize(str, lang)
7+
return tokens[0]
8+
} catch (_) {
9+
return null
10+
}
11+
}
12+
13+
const checkPackageNameToken = (tokens: monaco.Token[], tokenPos: number) => {
14+
let identPos = -1
15+
let foundIdent = false
16+
let foundDot = false
17+
18+
// Ensure that there is no delimiters or identifiers behind
19+
// to ensure that it's a first identifier in a chain.
20+
//
21+
// Expected backward pattern is:
22+
// <packageName>.
23+
//
24+
// Necessary to reject cases like:
25+
// foo.bar.baz
26+
for (let i = tokenPos; i >= 0; i--) {
27+
const tok = tokens[i]
28+
switch (tok.type) {
29+
case GoToken.Dot:
30+
if (foundDot || foundIdent) {
31+
return -1
32+
}
33+
34+
foundDot = true
35+
break
36+
case GoToken.None:
37+
if (!foundIdent) {
38+
// no ident
39+
return -1
40+
}
41+
42+
// we don't expect dots anymore
43+
foundDot = true
44+
break
45+
case GoToken.Ident:
46+
if (foundIdent) {
47+
// twice ident
48+
return -1
49+
}
50+
51+
foundIdent = true
52+
identPos = i
53+
break
54+
case GoToken.Parenthesis:
55+
return foundIdent ? identPos : -1
56+
default:
57+
// unexpected
58+
return -1
59+
}
60+
}
61+
62+
return identPos
63+
}
64+
65+
export interface HoverValue {
66+
packageName?: string
67+
value: string
68+
range: {
69+
startColumn: number
70+
endColumn: number
71+
}
72+
}
73+
74+
/**
75+
* Resolves symbol query from a currently focused token.
76+
*/
77+
const resolveHoverValue = (line: string, tokens: monaco.Token[], tokenPos: number): HoverValue | null => {
78+
const hoverValue = tokenToString(line, tokens, tokenPos)
79+
const tok = tokens[tokenPos]
80+
const range = {
81+
startColumn: tok.offset + 1,
82+
endColumn: tok.offset + hoverValue.length + 1,
83+
}
84+
85+
// check if there is a symbol behind to determine direction to move.
86+
const pkgNamePos = checkPackageNameToken(tokens, tokenPos - 1)
87+
if (pkgNamePos !== -1) {
88+
const pkgName = tokenToString(line, tokens, pkgNamePos)
89+
range.startColumn = tokens[pkgNamePos].offset + 1
90+
return {
91+
packageName: pkgName,
92+
value: hoverValue,
93+
range,
94+
}
95+
}
96+
97+
return { value: hoverValue, range }
98+
}
99+
100+
const keywordHoverValue = (line: string, tokens: monaco.Token[], tokenPos: number): HoverValue => {
101+
const value = tokenToString(line, tokens, tokenPos)
102+
const { offset } = tokens[tokenPos]
103+
return {
104+
value,
105+
range: {
106+
startColumn: offset + 1,
107+
endColumn: offset + value.length + 1,
108+
},
109+
}
110+
}
111+
112+
export const queryFromPosition = (
113+
model: monaco.editor.ITextModel,
114+
{ lineNumber, column }: monaco.IPosition,
115+
): HoverValue | null => {
116+
const line = model.getLineContent(lineNumber)
117+
const tokens = getLineTokens(line, model.getLanguageId())
118+
if (!tokens) {
119+
return null
120+
}
121+
122+
const offset = column - 1
123+
const r = tokenByOffset(tokens, offset, line.length)
124+
if (!r) {
125+
return null
126+
}
127+
128+
const { tok, index } = r
129+
if (tok.type === GoToken.Ident) {
130+
return resolveHoverValue(line, tokens, index)
131+
}
132+
133+
if (isKeywordValueToken(tok.type)) {
134+
return keywordHoverValue(line, tokens, index)
135+
}
136+
137+
if (tok.type !== GoToken.Ident) {
138+
return null
139+
}
140+
141+
return resolveHoverValue(line, tokens, index)
142+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type * as monaco from 'monaco-editor'
2+
import type { GoCompletionService } from '~/services/completion'
3+
import type { DocumentMetadataCache } from '../cache'
4+
import { queryFromPosition } from './parse'
5+
6+
export class GoHoverProvider implements monaco.languages.HoverProvider {
7+
private builtins?: Set<string>
8+
9+
constructor(
10+
protected completionSvc: GoCompletionService,
11+
protected metadataCache: DocumentMetadataCache,
12+
) {}
13+
14+
async provideHover(model: monaco.editor.ITextModel, position: monaco.Position, token: monaco.CancellationToken) {
15+
const query = queryFromPosition(model, position)
16+
if (!query) {
17+
return null
18+
}
19+
20+
// Skip resolution of unknown literals.
21+
const isLiteral = !('packageName' in query)
22+
if (isLiteral) {
23+
if (!this.builtins) {
24+
this.builtins = new Set(await this.completionSvc.getBuiltinNames())
25+
}
26+
27+
if (!this.builtins.has(query.value)) {
28+
return null
29+
}
30+
}
31+
32+
const { startColumn, endColumn } = query.range
33+
const imports = this.metadataCache.getMetadata(model.uri.path, model)
34+
const hoverValue = await this.completionSvc.getHoverValue({
35+
...query,
36+
context: {
37+
imports,
38+
range: {
39+
startLineNumber: position.lineNumber,
40+
endLineNumber: position.lineNumber,
41+
startColumn,
42+
endColumn,
43+
},
44+
},
45+
})
46+
47+
if (!hoverValue) {
48+
return null
49+
}
50+
51+
return hoverValue
52+
}
53+
}

web/src/components/features/workspace/CodeEditor/autocomplete/parse.test.ts web/src/components/features/workspace/CodeEditor/autocomplete/parser/imports.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as monaco from 'monaco-editor'
22
import path from 'path'
33
import { assert, describe, test } from 'vitest'
4-
import { importContextFromTokens } from './parse'
4+
import { importContextFromTokens } from './imports'
55
import { ImportClauseType, type ImportsContext } from '~/services/completion'
66

77
// Core language packs aren't loaded in vitest.

web/src/components/features/workspace/CodeEditor/autocomplete/parse.ts web/src/components/features/workspace/CodeEditor/autocomplete/parser/imports.ts

+1-14
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,7 @@
11
import * as monaco from 'monaco-editor'
22

33
import { ImportClauseType, type ImportsContext } from '~/services/completion'
4-
5-
type Tokens = monaco.Token[][]
6-
7-
enum GoToken {
8-
None = '',
9-
Comment = 'comment.go',
10-
KeywordPackage = 'keyword.package.go',
11-
KeywordImport = 'keyword.import.go',
12-
Parenthesis = 'delimiter.parenthesis.go',
13-
Ident = 'identifier.go',
14-
String = 'string.go',
15-
}
4+
import { isNotEmptyToken, GoToken, type Tokens } from './tokens'
165

176
class ParseError extends Error {
187
constructor(line: number, col: number, msg: string) {
@@ -26,8 +15,6 @@ class UnexpectedTokenError extends ParseError {
2615
}
2716
}
2817

29-
const isNotEmptyToken = ({ type }: monaco.Token) => type !== GoToken.None
30-
3118
const findPackageBlock = (tokens: Tokens) => {
3219
for (let i = 0; i < tokens.length; i++) {
3320
const row = tokens[i]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import type * as monaco from 'monaco-editor'
2+
3+
export type Tokens = monaco.Token[][]
4+
5+
const keywordRegex = /keyword\.(\w+)\.go/i
6+
const keywordValues = new Set([
7+
'bool',
8+
'true',
9+
'false',
10+
'uint8',
11+
'uint16',
12+
'uint32',
13+
'uint64',
14+
'int8',
15+
'int16',
16+
'int32',
17+
'int64',
18+
'float32',
19+
'float64',
20+
'complex64',
21+
'complex128',
22+
'byte',
23+
'rune',
24+
'uint',
25+
'int',
26+
'uintptr',
27+
'string',
28+
])
29+
30+
export enum GoToken {
31+
None = '',
32+
Comment = 'comment.go',
33+
KeywordPackage = 'keyword.package.go',
34+
KeywordImport = 'keyword.import.go',
35+
Parenthesis = 'delimiter.parenthesis.go',
36+
Square = 'delimiter.square.go',
37+
Dot = 'delimiter.go',
38+
Ident = 'identifier.go',
39+
String = 'string.go',
40+
Number = 'number.go',
41+
}
42+
43+
/**
44+
* Returns whether a passed token type is a document-able Go keyword.
45+
*/
46+
export const isKeywordValueToken = (tokenType: string) => {
47+
const m = keywordRegex.exec(tokenType)
48+
if (!m) {
49+
return false
50+
}
51+
52+
const [, keyword] = m
53+
return keywordValues.has(keyword)
54+
}
55+
56+
export const isNotEmptyToken = ({ type }: monaco.Token) => type !== GoToken.None
57+
58+
export const tokenByOffset = (tokens: monaco.Token[], offset: number, maxPos?: number) => {
59+
let left = 0
60+
let right = tokens.length - 1
61+
maxPos = maxPos ?? tokens[tokens.length - 1].offset
62+
63+
while (left <= right) {
64+
const mid = Math.floor((left + right) / 2)
65+
const tok = tokens[mid]
66+
67+
if (offset < tok.offset) {
68+
right = mid - 1
69+
continue
70+
}
71+
72+
const j = mid + 1
73+
const end = j < tokens.length ? tokens[j].offset : maxPos
74+
if (offset >= tok.offset && offset < end) {
75+
return {
76+
tok,
77+
index: mid,
78+
}
79+
}
80+
81+
left = mid + 1
82+
}
83+
84+
return null
85+
}
86+
87+
export const tokenToString = (line: string, tokens: monaco.Token[], pos: number) => {
88+
const start = tokens[pos].offset
89+
if (pos === tokens.length - 1) {
90+
return line.slice(start)
91+
}
92+
93+
const end = tokens[pos + 1].offset
94+
return line.slice(start, end)
95+
}

web/src/components/features/workspace/CodeEditor/autocomplete/register.ts

+2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as monaco from 'monaco-editor'
22

33
import { GoSymbolsCompletionItemProvider } from './symbols'
44
import { GoImportsCompletionProvider } from './imports'
5+
import { GoHoverProvider } from './hover'
56
import type { StateDispatch } from '~/store'
67
import { goCompletionService } from '~/services/completion'
78
import type { DocumentMetadataCache } from './cache'
@@ -19,5 +20,6 @@ export const registerGoLanguageProviders = (dispatcher: StateDispatch, cache: Do
1920
'go',
2021
new GoImportsCompletionProvider(dispatcher, goCompletionService),
2122
),
23+
monaco.languages.registerHoverProvider('go', new GoHoverProvider(goCompletionService, cache)),
2224
]
2325
}

0 commit comments

Comments
 (0)