Files
kjol/tools/tsgo/internal/ls/jsdoc_snippet.go
2026-07-09 16:50:43 -04:00

595 lines
18 KiB
Go

package ls
import (
"context"
"fmt"
"strings"
"github.com/microsoft/typescript-go/internal/ast"
"github.com/microsoft/typescript-go/internal/astnav"
"github.com/microsoft/typescript-go/internal/core"
"github.com/microsoft/typescript-go/internal/diagnostics"
"github.com/microsoft/typescript-go/internal/format"
"github.com/microsoft/typescript-go/internal/locale"
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
"github.com/microsoft/typescript-go/internal/parser"
"github.com/microsoft/typescript-go/internal/stringutil"
)
type docCommentTemplate struct {
newText string
}
type commentOwnerInfo struct {
commentOwner *ast.Node
parameters []*ast.ParameterDeclarationNode
hasReturn bool
}
func (l *LanguageService) getJSDocSnippetCompletion(ctx context.Context, file *ast.SourceFile, position int) *CompletionList {
if l.UserPreferences().EnableJSDocCompletions.IsFalse() {
return nil
}
if !isPotentiallyValidJSDocSnippetCompletionPosition(file, position) {
return nil
}
newLine := l.FormatOptions().NewLineCharacter
if newLine == "" {
newLine = "\n"
}
template := getDocCommentTemplateAtPosition(file, position, l.UserPreferences().GenerateReturnInDocTemplate.IsTrue(), newLine)
if template == nil {
return nil
}
insertText := template.newText
var insertTextFormat *lsproto.InsertTextFormat
if clientSupportsItemSnippet(ctx) {
insertText = templateToSnippet(insertText, newLine)
insertTextFormat = new(lsproto.InsertTextFormatSnippet)
}
editRange := l.getJSDocSnippetCompletionRange(ctx, file, position, insertText)
var commitCharacters *[]string
if clientSupportsItemCommitCharacters(ctx) {
commitCharacters = &[]string{}
}
item := &CompletionItem{
CompletionItem: &lsproto.CompletionItem{
Label: "/** */",
Kind: new(lsproto.CompletionItemKindText),
Detail: new(diagnostics.JSDoc_comment.Localize(locale.FromContext(ctx))),
SortText: new("\x00"),
InsertTextFormat: insertTextFormat,
TextEdit: editRange,
CommitCharacters: commitCharacters,
},
}
return &CompletionList{
IsIncomplete: false,
Items: []*CompletionItem{item},
}
}
func isPotentiallyValidJSDocSnippetCompletionPosition(file *ast.SourceFile, position int) bool {
text := file.Text()
lineStart := format.GetLineStartPositionForPosition(position, file)
prefix := text[lineStart:position]
if !isJSDocSnippetPrefix(prefix) {
return false
}
lineEnd := getLineEndOfPosition(file, position)
suffix := text[position:lineEnd]
return isJSDocSnippetSuffix(suffix)
}
func (l *LanguageService) getJSDocSnippetCompletionRange(ctx context.Context, file *ast.SourceFile, position int, newText string) *lsproto.TextEditOrInsertReplaceEdit {
text := file.Text()
lineStart := format.GetLineStartPositionForPosition(position, file)
prefix := text[lineStart:position]
start := position
if prefixStart, ok := getJSDocSnippetPrefixStart(prefix); ok {
start = lineStart + prefixStart
}
lineEnd := getLineEndOfPosition(file, position)
suffix := text[position:lineEnd]
end := position
if suffixEnd, ok := getJSDocSnippetSuffixEnd(suffix); ok {
end += suffixEnd
}
replacementRange := l.createLspRangeFromBounds(start, end, file)
if clientSupportsItemInsertReplace(ctx) {
return &lsproto.TextEditOrInsertReplaceEdit{
InsertReplaceEdit: &lsproto.InsertReplaceEdit{
NewText: newText,
Insert: replacementRange,
Replace: replacementRange,
},
}
}
return &lsproto.TextEditOrInsertReplaceEdit{
TextEdit: &lsproto.TextEdit{
NewText: newText,
Range: replacementRange,
},
}
}
func getDocCommentTemplateAtPosition(sourceFile *ast.SourceFile, position int, generateReturnInDocTemplate bool, newLine string) *docCommentTemplate {
tokenAtPos := astnav.GetTokenAtPosition(sourceFile, position)
if tokenAtPos == nil {
return nil
}
existingDocComment := ast.FindAncestor(tokenAtPos, ast.IsJSDoc)
docCommentEnd, hasDocCommentAtPosition, hasClosingDocCommentAtPosition := getDocCommentEndAtPosition(sourceFile, position)
isInEmptyDocComment := existingDocComment != nil || hasDocCommentAtPosition
if isNonEmptyJSDoc(existingDocComment) && hasDocCommentAtPosition && !hasClosingDocCommentAtPosition {
reparseText := sourceFile.Text()[:position] + " */" + sourceFile.Text()[position:]
reparse := parser.ParseSourceFile(sourceFile.ParseOptions(), reparseText, sourceFile.ScriptKind)
return getDocCommentTemplateAtPosition(reparse, position, generateReturnInDocTemplate, newLine)
}
if isNonEmptyJSDoc(existingDocComment) {
return nil
}
if existingDocComment == nil && hasDocCommentAtPosition {
tokenAtPos = astnav.GetTokenAtPosition(sourceFile, skipWhitespace(sourceFile.Text(), docCommentEnd))
if tokenAtPos == nil {
return nil
}
}
tokenStart := astnav.GetStartOfNode(tokenAtPos, sourceFile, false /*includeJSDoc*/)
if !isInEmptyDocComment && tokenStart < position {
return nil
}
commentOwnerInfo := getCommentOwnerInfo(tokenAtPos, generateReturnInDocTemplate)
if commentOwnerInfo == nil {
return nil
}
commentOwner := commentOwnerInfo.commentOwner
lastJSDoc := core.LastOrNil(commentOwner.JSDoc(sourceFile))
if commentOwnerStart := astnav.GetStartOfNode(commentOwner, sourceFile, false /*includeJSDoc*/); commentOwnerStart < position ||
lastJSDoc != nil && existingDocComment != nil && lastJSDoc != existingDocComment {
return nil
}
indentation := getIndentationStringAtPosition(sourceFile, position)
tags := parameterDocComments(commentOwnerInfo.parameters, ast.IsSourceFileJS(sourceFile), indentation, newLine)
if commentOwnerInfo.hasReturn {
tags += returnsDocComment(indentation, newLine)
}
if tags != "" && !hasJSDocTags(commentOwner, sourceFile) {
preamble := "/**" + newLine + indentation + " * "
endLine := ""
if tokenStart == position {
endLine = newLine + indentation
}
return &docCommentTemplate{newText: preamble + newLine + tags + indentation + " */" + endLine}
}
return &docCommentTemplate{newText: "/** */"}
}
func getDocCommentEndAtPosition(file *ast.SourceFile, position int) (end int, ok bool, hasClosing bool) {
text := file.Text()
lineStart := format.GetLineStartPositionForPosition(position, file)
lineEnd := getLineEndOfPosition(file, position)
prefix := text[lineStart:position]
suffix := text[position:lineEnd]
if !strings.HasSuffix(trimRightSingleLineWhitespace(prefix), "/**") {
return 0, false, false
}
suffixEnd, hasClosing := getJSDocSnippetSuffixEnd(suffix)
return position + suffixEnd, true, hasClosing
}
func skipWhitespace(text string, position int) int {
for position < len(text) {
ch, size := stringutil.DecodeJSStringRune(text[position:])
if size == 0 {
break
}
if !stringutil.IsWhiteSpaceLike(ch) {
break
}
position += size
}
return position
}
func getCommentOwnerInfo(tokenAtPos *ast.Node, generateReturnInDocTemplate bool) *commentOwnerInfo {
for node := tokenAtPos; node != nil; node = node.Parent {
info, quit := getCommentOwnerInfoWorker(node, generateReturnInDocTemplate)
if info != nil || quit {
return info
}
}
return nil
}
func getCommentOwnerInfoWorker(commentOwner *ast.Node, generateReturnInDocTemplate bool) (*commentOwnerInfo, bool) {
if commentOwner == nil {
return nil, false
}
switch commentOwner.Kind {
case ast.KindFunctionDeclaration, ast.KindFunctionExpression, ast.KindMethodDeclaration, ast.KindConstructor, ast.KindMethodSignature, ast.KindArrowFunction:
return &commentOwnerInfo{commentOwner: commentOwner, parameters: commentOwner.Parameters(), hasReturn: hasReturn(commentOwner, generateReturnInDocTemplate)}, false
case ast.KindPropertyAssignment:
return getCommentOwnerInfoWorker(commentOwner.AsPropertyAssignment().Initializer, generateReturnInDocTemplate)
case ast.KindClassDeclaration, ast.KindInterfaceDeclaration, ast.KindEnumDeclaration, ast.KindEnumMember, ast.KindTypeAliasDeclaration:
return &commentOwnerInfo{commentOwner: commentOwner}, false
case ast.KindPropertySignature:
if typeNode := commentOwner.AsPropertySignatureDeclaration().Type; typeNode != nil && ast.IsFunctionTypeNode(typeNode) {
return &commentOwnerInfo{commentOwner: commentOwner, parameters: typeNode.Parameters(), hasReturn: hasReturn(typeNode, generateReturnInDocTemplate)}, false
}
return &commentOwnerInfo{commentOwner: commentOwner}, false
case ast.KindVariableStatement:
declarations := commentOwner.AsVariableStatement().DeclarationList.AsVariableDeclarationList().Declarations.Nodes
if len(declarations) == 1 {
if initializer := declarations[0].AsVariableDeclaration().Initializer; initializer != nil {
if host := getRightHandSideOfAssignment(initializer); host != nil {
return &commentOwnerInfo{commentOwner: commentOwner, parameters: host.Parameters(), hasReturn: hasReturn(host, generateReturnInDocTemplate)}, false
}
}
}
return &commentOwnerInfo{commentOwner: commentOwner}, false
case ast.KindSourceFile:
return nil, true
case ast.KindModuleDeclaration:
if commentOwner.Parent.Kind == ast.KindModuleDeclaration {
return nil, false
}
return &commentOwnerInfo{commentOwner: commentOwner}, false
case ast.KindExpressionStatement:
return getCommentOwnerInfoWorker(commentOwner.AsExpressionStatement().Expression, generateReturnInDocTemplate)
case ast.KindBinaryExpression:
binaryExpression := commentOwner.AsBinaryExpression()
if ast.GetAssignmentDeclarationKind(commentOwner) == ast.JSDeclarationKindNone {
return nil, true
}
if ast.IsFunctionLike(binaryExpression.Right) {
return &commentOwnerInfo{commentOwner: commentOwner, parameters: binaryExpression.Right.Parameters(), hasReturn: hasReturn(binaryExpression.Right, generateReturnInDocTemplate)}, false
}
return &commentOwnerInfo{commentOwner: commentOwner}, false
case ast.KindPropertyDeclaration:
if initializer := commentOwner.AsPropertyDeclaration().Initializer; initializer != nil && ast.IsFunctionExpressionOrArrowFunction(initializer) {
return &commentOwnerInfo{commentOwner: commentOwner, parameters: initializer.Parameters(), hasReturn: hasReturn(initializer, generateReturnInDocTemplate)}, false
}
}
return nil, false
}
func hasReturn(node *ast.Node, generateReturnInDocTemplate bool) bool {
if !generateReturnInDocTemplate {
return false
}
if ast.IsFunctionTypeNode(node) {
return true
}
if ast.IsArrowFunction(node) {
if body := node.Body(); body != nil && ast.IsExpression(body) {
return true
}
}
return ast.IsFunctionLikeDeclaration(node) && node.Body() != nil && ast.IsBlock(node.Body()) && ast.ForEachReturnStatement(node.Body(), func(*ast.Node) bool {
return true
})
}
func getRightHandSideOfAssignment(rightHandSide *ast.Node) *ast.Node {
if rightHandSide == nil {
return nil
}
for rightHandSide.Kind == ast.KindParenthesizedExpression {
rightHandSide = rightHandSide.AsParenthesizedExpression().Expression
}
switch rightHandSide.Kind {
case ast.KindFunctionExpression, ast.KindArrowFunction:
return rightHandSide
case ast.KindClassExpression:
return core.Find(rightHandSide.Members(), ast.IsConstructorDeclaration)
default:
return nil
}
}
func parameterDocComments(parameters []*ast.ParameterDeclarationNode, isJavaScriptFile bool, indentation, newLine string) string {
var b strings.Builder
for i, parameter := range parameters {
paramName := fmt.Sprintf("param%d", i)
if ast.IsIdentifier(parameter.Name()) {
paramName = parameter.Name().Text()
}
paramType := ""
if isJavaScriptFile {
if parameter.AsParameterDeclaration().DotDotDotToken != nil {
paramType = "{...any} "
} else {
paramType = "{any} "
}
}
b.WriteString(indentation)
b.WriteString(" * @param ")
b.WriteString(paramType)
b.WriteString(paramName)
b.WriteString(newLine)
}
return b.String()
}
func returnsDocComment(indentation, newLine string) string {
return indentation + " * @returns" + newLine
}
func getIndentationStringAtPosition(sourceFile *ast.SourceFile, position int) string {
text := sourceFile.Text()
lineStart := format.GetLineStartPositionForPosition(position, sourceFile)
pos := lineStart
for pos < position {
ch, size := stringutil.DecodeJSStringRune(text[pos:])
if size == 0 {
break
}
if !stringutil.IsWhiteSpaceSingleLine(ch) {
break
}
pos += size
}
return text[lineStart:pos]
}
func isNonEmptyJSDoc(jsdoc *ast.Node) bool {
if jsdoc == nil {
return false
}
data := jsdoc.AsJSDoc()
return data.Comment != nil && len(data.Comment.Nodes) > 0 || data.Tags != nil && len(data.Tags.Nodes) > 0
}
func hasJSDocTags(node *ast.Node, file *ast.SourceFile) bool {
jsdocs := node.JSDoc(file)
if len(jsdocs) == 0 {
return false
}
tags := jsdocs[len(jsdocs)-1].AsJSDoc().Tags
return tags != nil && len(tags.Nodes) > 0
}
func templateToSnippet(template string, newLine string) string {
if template == "/** */" {
return "/**" + newLine + " * $0" + newLine + " */"
}
snippetIndex := 1
template = escapeSnippetText(template)
template = stripJSDocTemplateIndentation(template, newLine)
return transformJSDocTemplateLines(template, newLine, &snippetIndex)
}
func stripJSDocTemplateIndentation(template string, newLine string) string {
lines := strings.Split(template, newLine)
for i, line := range lines {
trimmed := strings.TrimLeft(line, " \t")
if strings.HasPrefix(trimmed, "/") {
lines[i] = trimmed
} else if strings.HasPrefix(trimmed, "*") {
lines[i] = " " + trimmed
}
}
return strings.Join(lines, newLine)
}
func transformJSDocTemplateLines(template string, newLine string, snippetIndex *int) string {
lines := strings.Split(template, newLine)
for i, line := range lines {
if i > 0 && strings.HasPrefix(lines[i-1], "/**") && lineHasOnlyJSDocAsterisk(line) {
lines[i] = line + "$0"
continue
}
if transformed, ok := transformJSDocParamLine(line, snippetIndex); ok {
lines[i] = transformed
continue
}
if transformed, ok := transformJSDocReturnsLine(line, snippetIndex); ok {
lines[i] = transformed
}
}
return strings.Join(lines, newLine)
}
func lineHasOnlyJSDocAsterisk(line string) bool {
line = strings.TrimLeft(line, " \t")
return strings.HasPrefix(line, "*") && isOnlySpacesOrTabs(line[1:])
}
func transformJSDocParamLine(line string, snippetIndex *int) (string, bool) {
prefix := ""
rest := line
if strings.HasPrefix(rest, " ") {
prefix = " "
rest = rest[1:]
}
if !strings.HasPrefix(rest, "* @param") {
return "", false
}
rest = rest[len("* @param"):]
if !startsWithSingleLineWhitespace(rest) {
return "", false
}
rest = strings.TrimLeft(rest, " \t")
var typeText string
if strings.HasPrefix(rest, "{") {
closeBrace := strings.IndexByte(rest, '}')
if closeBrace < 0 {
return "", false
}
typeText = " " + rest[:closeBrace+1]
rest = rest[closeBrace+1:]
if !startsWithSingleLineWhitespace(rest) {
return "", false
}
rest = strings.TrimLeft(rest, " \t")
}
paramName, rest, ok := scanNonWhitespace(rest)
if !ok || !isOnlySpacesOrTabs(rest) {
return "", false
}
out := prefix + "* @param "
if typeText == " {any}" || typeText == " {*}" {
out += fmt.Sprintf("{${%d:*}} ", *snippetIndex)
*snippetIndex++
} else if typeText != "" {
out += typeText + " "
}
out += fmt.Sprintf("%s ${%d}", paramName, *snippetIndex)
*snippetIndex++
return out, true
}
func transformJSDocReturnsLine(line string, snippetIndex *int) (string, bool) {
prefix := ""
rest := line
if strings.HasPrefix(rest, " ") {
prefix = " "
rest = rest[1:]
}
if !strings.HasPrefix(rest, "* @returns") || !isOnlySpacesOrTabs(rest[len("* @returns"):]) {
return "", false
}
text := fmt.Sprintf("%s* @returns ${%d}", prefix, *snippetIndex)
*snippetIndex++
return text, true
}
func scanNonWhitespace(text string) (word string, rest string, ok bool) {
if text == "" {
return "", "", false
}
for i := 0; i < len(text); {
ch, size := stringutil.DecodeJSStringRune(text[i:])
if size == 0 || stringutil.IsWhiteSpaceLike(ch) {
if i == 0 {
return "", "", false
}
return text[:i], text[i:], true
}
i += size
}
return text, "", true
}
func isJSDocSnippetPrefix(prefix string) bool {
trimmed := trimRightSingleLineWhitespace(prefix)
if strings.HasSuffix(trimmed, "/**") {
return true
}
start := skipSingleLineWhitespace(prefix, 0)
if start >= len(trimmed) || trimmed[start] != '/' {
return false
}
if start+3 > len(trimmed) {
return false
}
for i := start + 1; i < len(trimmed); i++ {
if trimmed[i] != '*' {
return false
}
}
return len(trimmed)-start >= 3
}
func getJSDocSnippetPrefixStart(prefix string) (int, bool) {
trimmed := trimRightSingleLineWhitespace(prefix)
for i := len(trimmed) - 1; i >= 0 && trimmed[i] == '*'; i-- {
if i > 0 && trimmed[i-1] == '/' {
return i - 1, true
}
}
if strings.HasSuffix(trimmed, "/") {
return len(trimmed) - 1, true
}
return 0, false
}
func isJSDocSnippetSuffix(suffix string) bool {
trimmed := trimRightSingleLineWhitespace(suffix[skipSingleLineWhitespace(suffix, 0):])
if trimmed == "" {
return true
}
if !strings.HasSuffix(trimmed, "/") {
return false
}
for i := range len(trimmed) - 1 {
if trimmed[i] != '*' {
return false
}
}
return true
}
func getJSDocSnippetSuffixEnd(suffix string) (int, bool) {
pos := skipSingleLineWhitespace(suffix, 0)
for pos < len(suffix) && suffix[pos] == '*' {
pos++
}
if pos < len(suffix) && suffix[pos] == '/' {
return pos + 1, true
}
return 0, false
}
func trimRightSingleLineWhitespace(text string) string {
end := 0
for pos := 0; pos < len(text); {
ch, size := stringutil.DecodeJSStringRune(text[pos:])
if size == 0 {
break
}
pos += size
if !stringutil.IsWhiteSpaceSingleLine(ch) {
end = pos
}
}
return text[:end]
}
func skipSingleLineWhitespace(text string, pos int) int {
for pos < len(text) {
ch, size := stringutil.DecodeJSStringRune(text[pos:])
if size == 0 || !stringutil.IsWhiteSpaceSingleLine(ch) {
break
}
pos += size
}
return pos
}
func isOnlySingleLineWhitespace(text string) bool {
return skipSingleLineWhitespace(text, 0) == len(text)
}
func startsWithSingleLineWhitespace(text string) bool {
if text == "" {
return false
}
ch, size := stringutil.DecodeJSStringRune(text)
return size != 0 && stringutil.IsWhiteSpaceSingleLine(ch)
}
func isOnlySpacesOrTabs(text string) bool {
for i := range len(text) {
if text[i] != ' ' && text[i] != '\t' {
return false
}
}
return true
}