diff --git a/internal/server/versions.go b/internal/server/versions.go index 987dd3b8..012dfcd5 100644 --- a/internal/server/versions.go +++ b/internal/server/versions.go @@ -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 ( diff --git a/web/src/components/features/workspace/CodeEditor/autocomplete/cache.ts b/web/src/components/features/workspace/CodeEditor/autocomplete/cache.ts index e935d35c..6124530c 100644 --- a/web/src/components/features/workspace/CodeEditor/autocomplete/cache.ts +++ b/web/src/components/features/workspace/CodeEditor/autocomplete/cache.ts @@ -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) diff --git a/web/src/components/features/workspace/CodeEditor/autocomplete/hover/index.ts b/web/src/components/features/workspace/CodeEditor/autocomplete/hover/index.ts new file mode 100644 index 00000000..6f29423e --- /dev/null +++ b/web/src/components/features/workspace/CodeEditor/autocomplete/hover/index.ts @@ -0,0 +1 @@ +export * from './provider' diff --git a/web/src/components/features/workspace/CodeEditor/autocomplete/hover/parse.ts b/web/src/components/features/workspace/CodeEditor/autocomplete/hover/parse.ts new file mode 100644 index 00000000..da5e3d0b --- /dev/null +++ b/web/src/components/features/workspace/CodeEditor/autocomplete/hover/parse.ts @@ -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: + // . + // + // 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) +} diff --git a/web/src/components/features/workspace/CodeEditor/autocomplete/hover/provider.ts b/web/src/components/features/workspace/CodeEditor/autocomplete/hover/provider.ts new file mode 100644 index 00000000..c8f3e7cd --- /dev/null +++ b/web/src/components/features/workspace/CodeEditor/autocomplete/hover/provider.ts @@ -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 + + 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 + } +} diff --git a/web/src/components/features/workspace/CodeEditor/autocomplete/parse.test.ts b/web/src/components/features/workspace/CodeEditor/autocomplete/parser/imports.test.ts similarity index 98% rename from web/src/components/features/workspace/CodeEditor/autocomplete/parse.test.ts rename to web/src/components/features/workspace/CodeEditor/autocomplete/parser/imports.test.ts index 2b1ec350..144e9605 100644 --- a/web/src/components/features/workspace/CodeEditor/autocomplete/parse.test.ts +++ b/web/src/components/features/workspace/CodeEditor/autocomplete/parser/imports.test.ts @@ -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. diff --git a/web/src/components/features/workspace/CodeEditor/autocomplete/parse.ts b/web/src/components/features/workspace/CodeEditor/autocomplete/parser/imports.ts similarity index 97% rename from web/src/components/features/workspace/CodeEditor/autocomplete/parse.ts rename to web/src/components/features/workspace/CodeEditor/autocomplete/parser/imports.ts index 6c5d0d60..97f989cb 100644 --- a/web/src/components/features/workspace/CodeEditor/autocomplete/parse.ts +++ b/web/src/components/features/workspace/CodeEditor/autocomplete/parser/imports.ts @@ -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] diff --git a/web/src/components/features/workspace/CodeEditor/autocomplete/testdata/corrupted.txt b/web/src/components/features/workspace/CodeEditor/autocomplete/parser/testdata/corrupted.txt similarity index 100% rename from web/src/components/features/workspace/CodeEditor/autocomplete/testdata/corrupted.txt rename to web/src/components/features/workspace/CodeEditor/autocomplete/parser/testdata/corrupted.txt diff --git a/web/src/components/features/workspace/CodeEditor/autocomplete/testdata/grouped.txt b/web/src/components/features/workspace/CodeEditor/autocomplete/parser/testdata/grouped.txt similarity index 100% rename from web/src/components/features/workspace/CodeEditor/autocomplete/testdata/grouped.txt rename to web/src/components/features/workspace/CodeEditor/autocomplete/parser/testdata/grouped.txt diff --git a/web/src/components/features/workspace/CodeEditor/autocomplete/testdata/hello.txt b/web/src/components/features/workspace/CodeEditor/autocomplete/parser/testdata/hello.txt similarity index 100% rename from web/src/components/features/workspace/CodeEditor/autocomplete/testdata/hello.txt rename to web/src/components/features/workspace/CodeEditor/autocomplete/parser/testdata/hello.txt diff --git a/web/src/components/features/workspace/CodeEditor/autocomplete/testdata/multiple.txt b/web/src/components/features/workspace/CodeEditor/autocomplete/parser/testdata/multiple.txt similarity index 100% rename from web/src/components/features/workspace/CodeEditor/autocomplete/testdata/multiple.txt rename to web/src/components/features/workspace/CodeEditor/autocomplete/parser/testdata/multiple.txt diff --git a/web/src/components/features/workspace/CodeEditor/autocomplete/testdata/single.txt b/web/src/components/features/workspace/CodeEditor/autocomplete/parser/testdata/single.txt similarity index 100% rename from web/src/components/features/workspace/CodeEditor/autocomplete/testdata/single.txt rename to web/src/components/features/workspace/CodeEditor/autocomplete/parser/testdata/single.txt diff --git a/web/src/components/features/workspace/CodeEditor/autocomplete/parser/tokens.ts b/web/src/components/features/workspace/CodeEditor/autocomplete/parser/tokens.ts new file mode 100644 index 00000000..511f918c --- /dev/null +++ b/web/src/components/features/workspace/CodeEditor/autocomplete/parser/tokens.ts @@ -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) +} diff --git a/web/src/components/features/workspace/CodeEditor/autocomplete/register.ts b/web/src/components/features/workspace/CodeEditor/autocomplete/register.ts index 3deb4a4c..3e48a205 100644 --- a/web/src/components/features/workspace/CodeEditor/autocomplete/register.ts +++ b/web/src/components/features/workspace/CodeEditor/autocomplete/register.ts @@ -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)), ] } diff --git a/web/src/services/completion/service.ts b/web/src/services/completion/service.ts index 30f05405..777539a2 100644 --- a/web/src/services/completion/service.ts +++ b/web/src/services/completion/service.ts @@ -1,5 +1,6 @@ +import type * as monaco from 'monaco-editor' import { db, keyValue } from '../storage' -import type { GoIndexFile, LiteralQuery, PackageSymbolQuery, SuggestionQuery } from './types' +import type { GoIndexFile, HoverQuery, LiteralQuery, PackageSymbolQuery, SuggestionQuery } from './types' import { completionFromPackage, completionFromSymbol, @@ -7,11 +8,14 @@ import { constructSymbols, findPackagePathFromContext, importCompletionFromPackage, + symbolHoverDoc, } from './utils' import { type SymbolIndexItem } from '~/services/storage/types' const completionVersionKey = 'completionItems.version' +const isPackageQuery = (q: SuggestionQuery): q is PackageSymbolQuery => 'packageName' in q + /** * Provides data sources for autocomplete services. */ @@ -36,6 +40,54 @@ export class GoCompletionService { return this.cachePopulated } + /** + * Returns list of predefined builtins. + * + * Used to speed-up hover operations. + */ + async getBuiltinNames() { + await this.checkCacheReady() + const items = await this.db.symbolIndex.where({ packageName: 'builtin' }).toArray() + return items.map(({ label }) => label) + } + + private buildHoverFilter(query: HoverQuery): Partial { + const isPackageMember = 'packageName' in query + if (!isPackageMember) { + return { + key: `builtin.${query.value}`, + } + } + + const pkgPath = findPackagePathFromContext(query.context, query.packageName) + if (pkgPath) { + return { + key: `${pkgPath}.${query.value}`, + } + } + + return { + packageName: query.packageName, + label: query.value, + } + } + + /** + * Returns hover documentation for a symbol. + */ + async getHoverValue(query: HoverQuery): Promise { + const filter = this.buildHoverFilter(query) + const entry = await this.db.symbolIndex.where(filter).first() + if (!entry) { + return null + } + + return { + contents: symbolHoverDoc(entry), + range: query.context.range, + } + } + /** * Returns list of known importable Go packages. * @@ -53,7 +105,7 @@ export class GoCompletionService { async getSymbolSuggestions(query: SuggestionQuery) { await this.checkCacheReady() - if ('packageName' in query) { + if (isPackageQuery(query)) { return await this.getMemberSuggestion(query) } diff --git a/web/src/services/completion/types/suggestion.ts b/web/src/services/completion/types/suggestion.ts index 19672dd7..7ee79291 100644 --- a/web/src/services/completion/types/suggestion.ts +++ b/web/src/services/completion/types/suggestion.ts @@ -92,3 +92,5 @@ export interface PackageSymbolQuery { } export type SuggestionQuery = LiteralQuery | PackageSymbolQuery + +export type HoverQuery = LiteralQuery | Required diff --git a/web/src/services/completion/utils.ts b/web/src/services/completion/utils.ts index 86db0eee..f5704502 100644 --- a/web/src/services/completion/utils.ts +++ b/web/src/services/completion/utils.ts @@ -151,3 +151,33 @@ export const findPackagePathFromContext = ({ imports }: SuggestionContext, pkgNa } } } + +const goDocDomain = 'pkg.go.dev' +export const symbolHoverDoc = ({ + label, + packageName, + packagePath, + signature, + documentation, +}: SymbolIndexItem): monaco.IMarkdownString[] => { + const doc: monaco.IMarkdownString[] = [] + + if (signature) { + doc.push({ + value: '```go\n' + signature + '\n```', + }) + } + + if (documentation) { + doc.push(documentation) + } + + const docLabel = packagePath === 'builtin' ? label : `${packageName}.${label}` + const linkLabel = `${docLabel} on ${goDocDomain}` + doc.push({ + value: `[${linkLabel}](https://${goDocDomain}/${packagePath}#${label})`, + isTrusted: true, + }) + + return doc +} diff --git a/web/src/services/storage/db.ts b/web/src/services/storage/db.ts index e417700d..5174f15d 100644 --- a/web/src/services/storage/db.ts +++ b/web/src/services/storage/db.ts @@ -15,7 +15,12 @@ export class DatabaseStorage extends Dexie { this.version(2).stores({ keyValue: 'key', packageIndex: 'importPath, prefix, name', - symbolIndex: 'key, packagePath, [packageName+prefix], [packageName+label], [packagePath+prefix]', + symbolIndex: ` + key, + packagePath, + [packageName+prefix], + [packageName+label], + [packagePath+prefix]`, }) } }