Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support documentation on hover #421

Merged
merged 4 commits into from
Oct 5, 2024
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/server/versions.go
Original file line number Diff line number Diff line change
@@ -5,7 +5,6 @@ import (
_ "embed"
"errors"
"fmt"
"golang.org/x/sync/errgroup"
"runtime"
"strings"
"time"
@@ -14,6 +13,7 @@ import (
"github.com/x1unix/go-playground/internal/util/syncx"
"github.com/x1unix/go-playground/pkg/goplay"
"go.uber.org/zap"
"golang.org/x/sync/errgroup"
)

const (
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type * as monaco from 'monaco-editor'
import type { ImportsContext } from '~/services/completion'
import { buildImportContext } from './parse'
import { buildImportContext } from './parser/imports'

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

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './provider'
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import * as monaco from 'monaco-editor'
import { GoToken, isKeywordValueToken, tokenByOffset, tokenToString } from '../parser/tokens'

const getLineTokens = (str: string, lang: string): monaco.Token[] | null => {
try {
const tokens = monaco.editor.tokenize(str, lang)
return tokens[0]
} catch (_) {
return null
}
}

const checkPackageNameToken = (tokens: monaco.Token[], tokenPos: number) => {
let identPos = -1
let foundIdent = false
let foundDot = false

// Ensure that there is no delimiters or identifiers behind
// to ensure that it's a first identifier in a chain.
//
// Expected backward pattern is:
// <packageName>.
//
// Necessary to reject cases like:
// foo.bar.baz
for (let i = tokenPos; i >= 0; i--) {
const tok = tokens[i]
switch (tok.type) {
case GoToken.Dot:
if (foundDot || foundIdent) {
return -1
}

foundDot = true
break
case GoToken.None:
if (!foundIdent) {
// no ident
return -1
}

// we don't expect dots anymore
foundDot = true
break
case GoToken.Ident:
if (foundIdent) {
// twice ident
return -1
}

foundIdent = true
identPos = i
break
case GoToken.Parenthesis:
return foundIdent ? identPos : -1
default:
// unexpected
return -1
}
}

return identPos
}

export interface HoverValue {
packageName?: string
value: string
range: {
startColumn: number
endColumn: number
}
}

/**
* Resolves symbol query from a currently focused token.
*/
const resolveHoverValue = (line: string, tokens: monaco.Token[], tokenPos: number): HoverValue | null => {
const hoverValue = tokenToString(line, tokens, tokenPos)
const tok = tokens[tokenPos]
const range = {
startColumn: tok.offset + 1,
endColumn: tok.offset + hoverValue.length + 1,
}

// check if there is a symbol behind to determine direction to move.
const pkgNamePos = checkPackageNameToken(tokens, tokenPos - 1)
if (pkgNamePos !== -1) {
const pkgName = tokenToString(line, tokens, pkgNamePos)
range.startColumn = tokens[pkgNamePos].offset + 1
return {
packageName: pkgName,
value: hoverValue,
range,
}
}

return { value: hoverValue, range }
}

const keywordHoverValue = (line: string, tokens: monaco.Token[], tokenPos: number): HoverValue => {
const value = tokenToString(line, tokens, tokenPos)
const { offset } = tokens[tokenPos]
return {
value,
range: {
startColumn: offset + 1,
endColumn: offset + value.length + 1,
},
}
}

export const queryFromPosition = (
model: monaco.editor.ITextModel,
{ lineNumber, column }: monaco.IPosition,
): HoverValue | null => {
const line = model.getLineContent(lineNumber)
const tokens = getLineTokens(line, model.getLanguageId())
if (!tokens) {
return null
}

const offset = column - 1
const r = tokenByOffset(tokens, offset, line.length)
if (!r) {
return null
}

const { tok, index } = r
if (tok.type === GoToken.Ident) {
return resolveHoverValue(line, tokens, index)
}

if (isKeywordValueToken(tok.type)) {
return keywordHoverValue(line, tokens, index)
}

if (tok.type !== GoToken.Ident) {
return null
}

return resolveHoverValue(line, tokens, index)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type * as monaco from 'monaco-editor'
import type { GoCompletionService } from '~/services/completion'
import type { DocumentMetadataCache } from '../cache'
import { queryFromPosition } from './parse'

export class GoHoverProvider implements monaco.languages.HoverProvider {
private builtins?: Set<string>

constructor(
protected completionSvc: GoCompletionService,
protected metadataCache: DocumentMetadataCache,
) {}

async provideHover(model: monaco.editor.ITextModel, position: monaco.Position, token: monaco.CancellationToken) {
const query = queryFromPosition(model, position)
if (!query) {
return null
}

// Skip resolution of unknown literals.
const isLiteral = !('packageName' in query)
if (isLiteral) {
if (!this.builtins) {
this.builtins = new Set(await this.completionSvc.getBuiltinNames())
}

if (!this.builtins.has(query.value)) {
return null
}
}

const { startColumn, endColumn } = query.range
const imports = this.metadataCache.getMetadata(model.uri.path, model)
const hoverValue = await this.completionSvc.getHoverValue({
...query,
context: {
imports,
range: {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn,
endColumn,
},
},
})

if (!hoverValue) {
return null
}

return hoverValue
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as monaco from 'monaco-editor'
import path from 'path'
import { assert, describe, test } from 'vitest'
import { importContextFromTokens } from './parse'
import { importContextFromTokens } from './imports'
import { ImportClauseType, type ImportsContext } from '~/services/completion'

// Core language packs aren't loaded in vitest.
Original file line number Diff line number Diff line change
@@ -1,18 +1,7 @@
import * as monaco from 'monaco-editor'

import { ImportClauseType, type ImportsContext } from '~/services/completion'

type Tokens = monaco.Token[][]

enum GoToken {
None = '',
Comment = 'comment.go',
KeywordPackage = 'keyword.package.go',
KeywordImport = 'keyword.import.go',
Parenthesis = 'delimiter.parenthesis.go',
Ident = 'identifier.go',
String = 'string.go',
}
import { isNotEmptyToken, GoToken, type Tokens } from './tokens'

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

const isNotEmptyToken = ({ type }: monaco.Token) => type !== GoToken.None

const findPackageBlock = (tokens: Tokens) => {
for (let i = 0; i < tokens.length; i++) {
const row = tokens[i]
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import type * as monaco from 'monaco-editor'

export type Tokens = monaco.Token[][]

const keywordRegex = /keyword\.(\w+)\.go/i
const keywordValues = new Set([
'bool',
'true',
'false',
'uint8',
'uint16',
'uint32',
'uint64',
'int8',
'int16',
'int32',
'int64',
'float32',
'float64',
'complex64',
'complex128',
'byte',
'rune',
'uint',
'int',
'uintptr',
'string',
])

export enum GoToken {
None = '',
Comment = 'comment.go',
KeywordPackage = 'keyword.package.go',
KeywordImport = 'keyword.import.go',
Parenthesis = 'delimiter.parenthesis.go',
Square = 'delimiter.square.go',
Dot = 'delimiter.go',
Ident = 'identifier.go',
String = 'string.go',
Number = 'number.go',
}

/**
* Returns whether a passed token type is a document-able Go keyword.
*/
export const isKeywordValueToken = (tokenType: string) => {
const m = keywordRegex.exec(tokenType)
if (!m) {
return false
}

const [, keyword] = m
return keywordValues.has(keyword)
}

export const isNotEmptyToken = ({ type }: monaco.Token) => type !== GoToken.None

export const tokenByOffset = (tokens: monaco.Token[], offset: number, maxPos?: number) => {
let left = 0
let right = tokens.length - 1
maxPos = maxPos ?? tokens[tokens.length - 1].offset

while (left <= right) {
const mid = Math.floor((left + right) / 2)
const tok = tokens[mid]

if (offset < tok.offset) {
right = mid - 1
continue
}

const j = mid + 1
const end = j < tokens.length ? tokens[j].offset : maxPos
if (offset >= tok.offset && offset < end) {
return {
tok,
index: mid,
}
}

left = mid + 1
}

return null
}

export const tokenToString = (line: string, tokens: monaco.Token[], pos: number) => {
const start = tokens[pos].offset
if (pos === tokens.length - 1) {
return line.slice(start)
}

const end = tokens[pos + 1].offset
return line.slice(start, end)
}
Original file line number Diff line number Diff line change
@@ -2,6 +2,7 @@ import * as monaco from 'monaco-editor'

import { GoSymbolsCompletionItemProvider } from './symbols'
import { GoImportsCompletionProvider } from './imports'
import { GoHoverProvider } from './hover'
import type { StateDispatch } from '~/store'
import { goCompletionService } from '~/services/completion'
import type { DocumentMetadataCache } from './cache'
@@ -19,5 +20,6 @@ export const registerGoLanguageProviders = (dispatcher: StateDispatch, cache: Do
'go',
new GoImportsCompletionProvider(dispatcher, goCompletionService),
),
monaco.languages.registerHoverProvider('go', new GoHoverProvider(goCompletionService, cache)),
]
}
Loading