Skip to content

Commit

Permalink
LSP: Implementing hover hints (#525)
Browse files Browse the repository at this point in the history
Within the typechecker, collect identifier information (if a flag is
set) into the TypedModule currently being typechecked. From within the
LanguageService, this metadata can be used to find the identifier being
hovered over and display some information about that identifier (name,
type/signature, import module (if imported), etc). Eventually there
will be more functionality here, like displaying fields, methods, enum
variants, etc.
  • Loading branch information
kengorab authored Dec 24, 2024
1 parent 2fcbb08 commit 7565eaa
Show file tree
Hide file tree
Showing 3 changed files with 226 additions and 9 deletions.
72 changes: 72 additions & 0 deletions projects/compiler/src/typechecker.abra
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import "fs" as fs
import log from "../../lsp/src/log"
import getAbsolutePath, resolveRelativePath from "./utils"
import Lexer, LexerError, Token, TokenKind, Position from "./lexer"
import Parser, ParsedModule, ParseError, AstNode, AstNodeKind, LiteralAstNode, UnaryAstNode, UnaryOp, BinaryAstNode, BinaryOp, BindingDeclarationNode, BindingPattern, TypeIdentifier, Label, IdentifierKind, FunctionDeclarationNode, FunctionParam, InvocationAstNode, InvocationArgument, TypeDeclarationNode, EnumDeclarationNode, EnumVariant, AccessorAstNode, IndexingMode, AssignOp, AssignmentMode, ImportNode, ImportKind, DecoratorNode, LambdaNode, MatchCase, MatchCaseKind from "./parser"
Expand Down Expand Up @@ -88,6 +89,18 @@ type ImportedModule {
imports: Map<String, Import> = {}
}

export enum IdentifierKindMeta {
Variable(mutable: Bool, typeRepr: String)
Function(typeParams: String[], params: String[], returnTypeRepr: String)
Type(isEnum: Bool, typeParams: String[])
}

export type IdentifierMeta {
name: String
kind: IdentifierKindMeta
importedFrom: String? = None
}

export type TypedModule {
id: Int
name: String
Expand All @@ -96,6 +109,7 @@ export type TypedModule {
complete: Bool = false
imports: Map<String, ImportedModule> = {}
exports: Map<String, Export> = {}
identsByLine: Map<Int, (Int, Int, IdentifierMeta)[]> = {}
}

export enum VariableAlias {
Expand Down Expand Up @@ -1485,13 +1499,18 @@ export type Typechecker {
isEnumContainerValueAllowed: Bool = false
numLambdas: Int = 0
typecheckingBuiltin: BuiltinModule? = None
trackIdentsForLsp: Bool = false
identsByLine: Map<Int, (Int, Int, IdentifierMeta)[]> = {}

func typecheckEntrypoint(self, modulePathAbs: String): Result<TypedModule, TypecheckerError> {
val preludeModulePathSegs = getAbsolutePath(self.moduleLoader.stdRoot + "/prelude.abra")
val preludeModulePathAbs = "/" + preludeModulePathSegs.join("/")

if !self.project.modules[preludeModulePathAbs] {
val trackIdentsForLsp = self.trackIdentsForLsp
self.trackIdentsForLsp = false
try self._typecheckModule(preludeModulePathAbs)
self.trackIdentsForLsp = trackIdentsForLsp

val preludeStructs = [
self.project.preludeIntStruct,
Expand Down Expand Up @@ -2169,6 +2188,8 @@ export type Typechecker {

mod.complete = true

mod.identsByLine = self.identsByLine

if self.typecheckingBuiltin {
self.typecheckingBuiltin = None
}
Expand Down Expand Up @@ -3033,6 +3054,20 @@ export type Typechecker {
val variable = Variable(label: label, scope: self.currentScope, mutable: mutable, ty: ty)
try self._addVariableToScope(variable)

if self.trackIdentsForLsp {
val ident = IdentifierMeta(
name: label.name,
kind: IdentifierKindMeta.Variable(mutable: mutable, typeRepr: ty.repr()),
)

val v = (label.position.col - 1, label.position.col + label.name.length - 1, ident)
if self.identsByLine[label.position.line - 1] |idents| {
idents.push(v)
} else {
self.identsByLine[label.position.line - 1] = [v]
}
}

Ok([variable])
}
BindingPattern.Tuple(lParenTok, patterns) => {
Expand Down Expand Up @@ -4005,10 +4040,47 @@ export type Typechecker {
}
return Err(TypeError(position: token.position, kind: TypeErrorKind.UnknownName("self", "variable")))
}

(resolvedIdentifier, "self", None)
}
}

if self.trackIdentsForLsp {
val kind = match variable.alias {
None => IdentifierKindMeta.Variable(variable.mutable, variable.ty.repr())
VariableAlias.Function(fn) => {
val typeParams: String[] = []
for (_, label) in fn.typeParams {
typeParams.push(label.name)
}

val params: String[] = []
for param in fn.params {
params.push("${if param.isVariadic "..." else ""}${param.label.name}${if param.defaultValue "?" else ""}: ${param.ty.repr()}")
}

val returnTypeRepr = fn.returnType.repr()

IdentifierKindMeta.Function(typeParams, params, returnTypeRepr)
}
VariableAlias.Struct(struct) => IdentifierKindMeta.Type(isEnum: false, typeParams: struct.typeParams)
VariableAlias.Enum(_enum) => IdentifierKindMeta.Type(isEnum: true, typeParams: _enum.typeParams)
}

val ident = IdentifierMeta(
name: name,
kind: kind,
importedFrom: varImportMod?.name,
)

val v = (token.position.col - 1, token.position.col + name.length - 1, ident)
if self.identsByLine[token.position.line - 1] |idents| {
idents.push(v)
} else {
self.identsByLine[token.position.line - 1] = [v]
}
}

Ok(TypedAstNode(token: token, ty: variable.ty, kind: TypedAstNodeKind.Identifier(name, variable, fnTypeHint, varImportMod)))
}

Expand Down
56 changes: 52 additions & 4 deletions projects/lsp/src/language_service.abra
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import "fs" as fs
import JsonValue from "json"
import log from "./log"
import ModuleLoader, Project, Typechecker, TypecheckerErrorKind from "../../compiler/src/typechecker"
import RequestMessage, NotificationMessage, ResponseMessage, ResponseResult, ResponseError, ResponseErrorCode, ServerCapabilities, TextDocumentSyncOptions, TextDocumentSyncKind, SaveOptions, ServerInfo, TextDocumentItem, TextDocumentIdentifier, VersionedTextDocumentIdentifier, TextDocumentContentChangeEvent, Diagnostic, DiagnosticSeverity from "./lsp_spec"
import ModuleLoader, Project, Typechecker, TypecheckerErrorKind, IdentifierKindMeta from "../../compiler/src/typechecker"
import RequestMessage, NotificationMessage, ResponseMessage, ResponseResult, ResponseError, ResponseErrorCode, ServerCapabilities, TextDocumentSyncOptions, TextDocumentSyncKind, SaveOptions, ServerInfo, TextDocumentItem, TextDocumentIdentifier, VersionedTextDocumentIdentifier, TextDocumentContentChangeEvent, Diagnostic, DiagnosticSeverity, Position, Range, MarkupContent, MarkupKind from "./lsp_spec"

export val contentLengthHeader = "Content-Length: "
export val bogusMessageId = -999
Expand Down Expand Up @@ -47,13 +47,56 @@ export type AbraLanguageService {
openClose: Some(false),
change: Some(TextDocumentSyncKind.None_),
save: Some(SaveOptions(includeText: Some(false)))
))
)),
hoverProvider: Some(true),
),
serverInfo: ServerInfo(name: "abra-lsp", version: Some("0.0.1"))
)
ResponseMessage.Success(id: id, result: Some(result))
}

func _hover(self, id: Int, textDocument: TextDocumentIdentifier, position: Position): ResponseMessage {
// todo: what happens if it's not a `file://` uri?
val filePath = textDocument.uri.replaceAll("file://", "")
val module = if self._project.modules[filePath] |mod| mod else return ResponseMessage.Success(id: id, result: None)
val line = position.line
val identsByLine = if module.identsByLine[line] |idents| idents else return ResponseMessage.Success(id: id, result: None)

for (colStart, colEnd, ident) in identsByLine {
if colStart <= position.character && position.character <= colEnd {
val message = match ident.kind {
IdentifierKindMeta.Variable(mutable, typeRepr) => {
val prefix = if mutable "var" else "val"
"$prefix ${ident.name}: $typeRepr"
}
IdentifierKindMeta.Function(typeParams, params, returnTypeRepr) => {
val generics = if typeParams.isEmpty() "" else "<${typeParams.join(", ")}>"
"func ${ident.name}$generics(${params.join(", ")}): $returnTypeRepr"
}
IdentifierKindMeta.Type(isEnum, typeParams) => {
val prefix = if isEnum "enum" else "type"
val generics = if typeParams.isEmpty() "" else "<${typeParams.join(", ")}>"
"$prefix ${ident.name}$generics"
}
}
val lines = ["```abra", message, "```"]

if ident.importedFrom |modName| {
lines.push("Imported from `$modName`")
}

val value = lines.join("\\n")

val range = Range(start: Position(line: line, character: colStart), end: Position(line: line, character: colEnd))
val contents = MarkupContent(kind: MarkupKind.Markdown, value: value)
val result = ResponseResult.Hover(contents: contents, range: Some(range))
return ResponseMessage.Success(id: id, result: Some(result))
}
}

ResponseMessage.Success(id: id, result: None)
}

// Notification handlers

func _textDocumentDidOpen(self, textDocument: TextDocumentItem) {
Expand Down Expand Up @@ -101,7 +144,11 @@ export type AbraLanguageService {
// todo: what happens if it's not a `file://` uri?
val filePath = uri.replaceAll("file://", "")

val typechecker = Typechecker(moduleLoader: self._moduleLoader, project: self._project)
val typechecker = Typechecker(
moduleLoader: self._moduleLoader,
project: self._project,
trackIdentsForLsp: true,
)

match typechecker.typecheckEntrypoint(filePath) {
Ok => []
Expand Down Expand Up @@ -150,6 +197,7 @@ export type AbraLanguageService {
func handleRequest(self, req: RequestMessage): ResponseMessage {
match req {
RequestMessage.Initialize(id, processId, rootPath) => self._initialize(id, processId, rootPath)
RequestMessage.Hover(id, textDocument, position) => self._hover(id, textDocument, position)
}
}

Expand Down
107 changes: 102 additions & 5 deletions projects/lsp/src/lsp_spec.abra
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import JsonValue, JsonError, JsonObject from "json"

export enum RequestMessage {
Initialize(id: Int, processId: Int?, rootPath: String?)
Hover(id: Int, textDocument: TextDocumentIdentifier, position: Position)

func fromJson(json: JsonValue): Result<RequestMessage?, JsonError> {
val obj = try json.asObject()
Expand All @@ -25,6 +26,16 @@ export enum RequestMessage {

Ok(Some(RequestMessage.Initialize(id: id, processId: processId, rootPath: rootPath)))
}
"textDocument/hover" => {
val params = try obj.getObjectRequired("params")
val textDocumentObj = try params.getValueRequired("textDocument")
val textDocument = try TextDocumentIdentifier.fromJson(textDocumentObj)

val positionObj = try params.getValueRequired("position")
val position = try Position.fromJson(positionObj)

Ok(Some(RequestMessage.Hover(id: id, textDocument: textDocument, position: position)))
}
else => {
log.writeln("Error: Unimplemented RequestMessage method '$method'")

Expand Down Expand Up @@ -128,14 +139,26 @@ export enum ResponseMessage {

export enum ResponseResult {
Initialize(capabilities: ServerCapabilities, serverInfo: ServerInfo)
Hover(contents: MarkupContent, range: Range?)

func toJson(self): JsonValue {
val obj = JsonObject()

match self {
val obj = match self {
ResponseResult.Initialize(capabilities, serverInfo) => {
obj.set("capabilities", capabilities.toJson())
obj.set("serverInfo", serverInfo.toJson())
JsonObject(_map: {
"capabilities": capabilities.toJson(),
"serverInfo": serverInfo.toJson()
})
}
ResponseResult.Hover(contents, range) => {
val obj = JsonObject(_map: {
contents: contents.toJson()
})

if range |range| {
obj.set("range", range.toJson())
}

obj
}
}

Expand Down Expand Up @@ -188,6 +211,7 @@ export enum ResponseErrorCode {
export type ServerCapabilities {
textDocumentSync: TextDocumentSyncOptions? = None
diagnosticProvider: DiagnosticOptions? = None
hoverProvider: Bool? = None

func toJson(self): JsonValue {
val obj = JsonObject()
Expand All @@ -200,6 +224,10 @@ export type ServerCapabilities {
obj.set("diagnosticProvider", dp.toJson())
}

if self.hoverProvider |hp| {
obj.set("hoverProvider", JsonValue.Boolean(hp))
}

JsonValue.Object(obj)
}
}
Expand Down Expand Up @@ -327,6 +355,55 @@ export type VersionedTextDocumentIdentifier {
}
}

export type Position {
// line and character are both zero-based
line: Int
character: Int

func fromJson(json: JsonValue): Result<Position, JsonError> {
val obj = try json.asObject()
val line = match try obj.getNumberRequired("line") {
Either.Left(int) => int
Either.Right(float) => float.asInt()
}
val character = match try obj.getNumberRequired("character") {
Either.Left(int) => int
Either.Right(float) => float.asInt()
}

Ok(Position(line: line, character: character))
}

func toJson(self): JsonValue {
JsonValue.Object(JsonObject(_map: {
line: JsonValue.Number(Either.Left(self.line)),
character: JsonValue.Number(Either.Left(self.character)),
}))
}
}

export type Range {
start: Position
end: Position // exclusive

func fromJson(json: JsonValue): Result<Range, JsonError> {
val obj = try json.asObject()
val startObj = try obj.getValueRequired("start")
val start = try Position.fromJson(startObj)
val endObj = try obj.getValueRequired("end")
val end = try Position.fromJson(endObj)

Ok(Range(start: start, end: end))
}

func toJson(self): JsonValue {
JsonValue.Object(JsonObject(_map: {
start: self.start.toJson(),
end: self.end.toJson(),
}))
}
}

export enum TextDocumentContentChangeEvent {
Incremental(
range: ((Int, Int), (Int, Int)), // (line: Int, character: Int), zero-based
Expand Down Expand Up @@ -388,3 +465,23 @@ export enum DiagnosticSeverity {
DiagnosticSeverity.Hint => 4
}
}

export type MarkupContent {
kind: MarkupKind
value: String

func toJson(self): JsonValue {
JsonValue.Object(JsonObject(_map: {
kind: JsonValue.String(match self.kind {
MarkupKind.PlainText => "plaintext"
MarkupKind.Markdown => "markdown"
}),
value: JsonValue.String(self.value)
}))
}
}

export enum MarkupKind {
PlainText
Markdown
}

0 comments on commit 7565eaa

Please sign in to comment.