From 7f7b257c3e2797853d9a118badc8b42674bc930f Mon Sep 17 00:00:00 2001 From: Zzzen Date: Fri, 27 Mar 2026 22:20:05 +0800 Subject: [PATCH] support quick info and go to definition on mapped keys --- internal/checker/checker.go | 1 + internal/checker/services.go | 8 + .../tests/goToDefinitionMappedType2_test.go | 32 +++ .../tests/goToDefinitionMappedType3_test.go | 41 ++++ .../tests/quickInfoMappedType2_test.go | 34 ++++ .../tests/quickInfoMappedType3_test.go | 58 ++++++ .../tests/quickInfoMappedType4_test.go | 32 +++ internal/ls/definition.go | 6 + internal/ls/hover.go | 42 ++++ internal/parser/parser.go | 5 +- internal/parser/utilities.go | 2 +- .../goToDefinitionMappedType2.baseline.jsonc | 15 ++ .../goToDefinitionMappedType3.baseline.jsonc | 14 ++ .../quickInfo/quickInfoMappedType3.baseline | 190 ++++++++++++++++++ 14 files changed, 478 insertions(+), 2 deletions(-) create mode 100644 internal/fourslash/tests/goToDefinitionMappedType2_test.go create mode 100644 internal/fourslash/tests/goToDefinitionMappedType3_test.go create mode 100644 internal/fourslash/tests/quickInfoMappedType2_test.go create mode 100644 internal/fourslash/tests/quickInfoMappedType3_test.go create mode 100644 internal/fourslash/tests/quickInfoMappedType4_test.go create mode 100644 testdata/baselines/reference/fourslash/goToDefinition/goToDefinitionMappedType2.baseline.jsonc create mode 100644 testdata/baselines/reference/fourslash/goToDefinition/goToDefinitionMappedType3.baseline.jsonc create mode 100644 testdata/baselines/reference/fourslash/quickInfo/quickInfoMappedType3.baseline diff --git a/internal/checker/checker.go b/internal/checker/checker.go index 940d0f98f85..afe92781ae2 100644 --- a/internal/checker/checker.go +++ b/internal/checker/checker.go @@ -20397,6 +20397,7 @@ func (c *Checker) resolveMappedTypeMembers(t *Type) { } prop := c.newSymbol(ast.SymbolFlagsProperty|core.IfElse(isOptional, ast.SymbolFlagsOptional, 0), propName) prop.CheckFlags = lateFlag | ast.CheckFlagsMapped | core.IfElse(isReadonly, ast.CheckFlagsReadonly, 0) | core.IfElse(stripOptional, ast.CheckFlagsStripOptional, 0) + prop.Parent = mappedType.symbol valueLinks := c.valueSymbolLinks.Get(prop) valueLinks.containingType = t valueLinks.nameType = propNameType diff --git a/internal/checker/services.go b/internal/checker/services.go index 83904ee0c28..62dec791003 100644 --- a/internal/checker/services.go +++ b/internal/checker/services.go @@ -408,6 +408,14 @@ func (c *Checker) GetMappedTypeSymbolOfProperty(symbol *ast.Symbol) *ast.Symbol return nil } +// GetMappedSyntheticOrigin returns the syntheticOrigin of a mapped symbol (the original property it was derived from). +func (c *Checker) GetMappedSyntheticOrigin(symbol *ast.Symbol) *ast.Symbol { + if symbol.CheckFlags&ast.CheckFlagsMapped != 0 && c.mappedSymbolLinks.Has(symbol) { + return c.mappedSymbolLinks.Get(symbol).syntheticOrigin + } + return nil +} + func (c *Checker) getImmediateRootSymbols(symbol *ast.Symbol) []*ast.Symbol { if symbol.CheckFlags&ast.CheckFlagsSynthetic != 0 { return core.MapNonNil( diff --git a/internal/fourslash/tests/goToDefinitionMappedType2_test.go b/internal/fourslash/tests/goToDefinitionMappedType2_test.go new file mode 100644 index 00000000000..cc4edff87af --- /dev/null +++ b/internal/fourslash/tests/goToDefinitionMappedType2_test.go @@ -0,0 +1,32 @@ +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestGoToDefinitionMappedType2(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `interface Foo { + /*def*/property: string +} + +type JustMapIt = {[P in keyof T]: 0} +type MapItWithRemap = {[P in keyof T as P extends string ? ` + "`mapped_${P}`" + ` : never]: 0} + +{ + let gotoDef!: JustMapIt + gotoDef.property +} + +{ + let gotoDef!: MapItWithRemap + gotoDef.[|/*ref*/mapped_property|] +}` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyBaselineGoToDefinition(t, true, "ref") +} diff --git a/internal/fourslash/tests/goToDefinitionMappedType3_test.go b/internal/fourslash/tests/goToDefinitionMappedType3_test.go new file mode 100644 index 00000000000..94994e7a8fd --- /dev/null +++ b/internal/fourslash/tests/goToDefinitionMappedType3_test.go @@ -0,0 +1,41 @@ +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestGoToDefinitionMappedType3(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `interface Source { + /*def*/alpha: number; + beta: string; +} + +// Transforming interface field names with a suffix +type Transformed = { + [K in keyof T as ` + "`${K & string}Suffix`" + `]: () => T[K]; +}; + +type Result = Transformed; +/* + Expected: + { + alphaSuffix: () => number; + betaSuffix: () => string; + } + */ + +const obj: Result = { + alphaSuffix: () => 42, + betaSuffix: () => "hello", +}; + +obj.[|/*ref*/alphaSuffix|]();` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyBaselineGoToDefinition(t, true, "ref") +} diff --git a/internal/fourslash/tests/quickInfoMappedType2_test.go b/internal/fourslash/tests/quickInfoMappedType2_test.go new file mode 100644 index 00000000000..f6261572869 --- /dev/null +++ b/internal/fourslash/tests/quickInfoMappedType2_test.go @@ -0,0 +1,34 @@ +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestQuickInfoMappedType2(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + // Tests that @inheritDoc on a MappedTypeNode causes hover to show documentation. + // TODO: once @inheritDoc resolution is fully implemented, the expected documentation + // should be "desc on Getters\nhello" (combined from the mapped type and the source property). + const content = `type ToGet = T extends string ? ` + "`get${Capitalize}`" + ` : never; +type Getters = /** @inheritDoc desc on Getters */ { + [P in keyof T as ToGet

]: () => T[P] +}; + +type Y = { + /** hello */ + d: string; +} + +type T50 = Getters; // { getFoo: () => string, getBar: () => number } + +declare let y: T50; +y.get/*3*/D;` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + // Current Go behavior: shows the raw @inheritDoc tag text from the mapped type declaration. + f.VerifyQuickInfoAt(t, "3", "(property) getD: () => string", "\n\n*@inheritDoc* \u2014 desc on Getters ") +} diff --git a/internal/fourslash/tests/quickInfoMappedType3_test.go b/internal/fourslash/tests/quickInfoMappedType3_test.go new file mode 100644 index 00000000000..87722bba80a --- /dev/null +++ b/internal/fourslash/tests/quickInfoMappedType3_test.go @@ -0,0 +1,58 @@ +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestQuickInfoMappedType3(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `type Getters = /** @inheritDoc desc on Getters */ { + [Property in keyof Type as ` + "`" + `get${Capitalize< + string & Property + >}` + "`" + `]: () => Type[Property]; +}; + +interface Person { + // ✅ When hovering here, the documentation is displayed, as it should. + /** + * Person's name. + * @example "John Doe" + */ + name: string; + + // ✅ When hovering here, the documentation is displayed, as it should. + /** + * Person's Age. + * @example 30 + */ + age: number; + + // ✅ When hovering here, the documentation is displayed, as it should. + /** + * Person's Location. + * @example "Brazil" + */ + location: string; +} + +type LazyPerson = Getters; + +const me: LazyPerson = { + // ❌ When hovering here, the documentation is NOT displayed. + /*1*/getName: () => "Jake Carter", + // ❌ When hovering here, the documentation is NOT displayed. + /*2*/getAge: () => 35, + // ❌ When hovering here, the documentation is NOT displayed. + /*3*/getLocation: () => "United States", +}; + +// ❌ When hovering here, the documentation is NOT displayed. +me./*4*/getName();` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyBaselineHover(t) +} diff --git a/internal/fourslash/tests/quickInfoMappedType4_test.go b/internal/fourslash/tests/quickInfoMappedType4_test.go new file mode 100644 index 00000000000..220f943f7b7 --- /dev/null +++ b/internal/fourslash/tests/quickInfoMappedType4_test.go @@ -0,0 +1,32 @@ +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestQuickInfoMappedType4(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `type ToGet = T extends string ? ` + "`get${Capitalize}`" + ` : never; +type Getters = { + /** @inheritDoc desc on Getters */ + [P in keyof T as ToGet

]: () => T[P] + + }; + +type Y = { + /** hello */ + d: string; +} + +type T50 = Getters; // { getFoo: () => string, getBar: () => number } + +declare let y: T50; +y.get/*3*/D;` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyQuickInfoAt(t, "3", "(property) getD: () => string", "") +} diff --git a/internal/ls/definition.go b/internal/ls/definition.go index ace5c5c5ca9..87800784a09 100644 --- a/internal/ls/definition.go +++ b/internal/ls/definition.go @@ -264,6 +264,12 @@ func getDeclarationsFromLocation(c *checker.Checker, node *ast.Node) []*ast.Node if len(symbol.Declarations) > 0 { return symbol.Declarations } + // For mapped type properties with no declarations, fall back to syntheticOrigin declarations + if symbol.CheckFlags&ast.CheckFlagsMapped != 0 { + if syntheticOrigin := c.GetMappedSyntheticOrigin(symbol); syntheticOrigin != nil && len(syntheticOrigin.Declarations) > 0 { + return syntheticOrigin.Declarations + } + } } if indexInfos := c.GetIndexSignaturesAtLocation(node); len(indexInfos) != 0 { return indexInfos diff --git a/internal/ls/hover.go b/internal/ls/hover.go index 0e3ca68c1ed..9e2e319c0c8 100644 --- a/internal/ls/hover.go +++ b/internal/ls/hover.go @@ -71,6 +71,19 @@ func (l *LanguageService) getQuickInfoAndDocumentationForSymbol(c *checker.Check func (l *LanguageService) getDocumentationFromDeclaration(c *checker.Checker, symbol *ast.Symbol, declaration *ast.Node, location *ast.Node, contentFormat lsproto.MarkupKind, commentOnly bool) string { if declaration == nil { + // For mapped type symbols with @inheritDoc on the mapped type, synthesize documentation + // from the mapped type's declarations and the syntheticOrigin's declarations. + if symbol != nil && symbol.CheckFlags&ast.CheckFlagsMapped != 0 && + symbol.Parent != nil && len(symbol.Parent.Declarations) > 0 && + core.Some(symbol.Parent.Declarations, hasJSDocInheritDocTag) { + syntheticOrigin := c.GetMappedSyntheticOrigin(symbol) + var decls []*ast.Node + decls = append(decls, symbol.Parent.Declarations...) + if syntheticOrigin != nil { + decls = append(decls, syntheticOrigin.Declarations...) + } + return l.getDocumentationFromDeclarations(c, decls, location, contentFormat, commentOnly) + } return "" } @@ -187,6 +200,35 @@ func (l *LanguageService) getDocumentationFromDeclaration(c *checker.Checker, sy return b.String() } +// hasJSDocInheritDocTag reports whether the given node has a @inheritDoc JSDoc tag. +func hasJSDocInheritDocTag(node *ast.Node) bool { + for _, jsdoc := range node.JSDoc(nil) { + if jsdoc.Kind != ast.KindJSDoc { + continue + } + tags := jsdoc.AsJSDoc().Tags + if tags == nil { + continue + } + for _, tag := range tags.Nodes { + if tag.Kind == ast.KindJSDocTag && tag.TagName().Text() == "inheritDoc" { + return true + } + } + } + return false +} + +// getDocumentationFromDeclarations gets documentation from the first declaration in decls that has JSDoc. +func (l *LanguageService) getDocumentationFromDeclarations(c *checker.Checker, decls []*ast.Node, location *ast.Node, contentFormat lsproto.MarkupKind, commentOnly bool) string { + for _, decl := range decls { + if s := l.getDocumentationFromDeclaration(c, nil, decl, location, contentFormat, commentOnly); s != "" { + return s + } + } + return "" +} + func getCommentText(comments []*ast.Node) string { var b strings.Builder for _, comment := range comments { diff --git a/internal/parser/parser.go b/internal/parser/parser.go index dbfdb6d4618..ddb151cc3ca 100644 --- a/internal/parser/parser.go +++ b/internal/parser/parser.go @@ -3120,6 +3120,7 @@ func (p *Parser) nextIsStartOfMappedType() bool { func (p *Parser) parseMappedType() *ast.Node { pos := p.nodePos() + hasJSDoc := p.jsdocScannerInfo() p.parseExpected(ast.KindOpenBraceToken) var readonlyToken *ast.Node // ReadonlyKeyword | PlusToken | MinusToken if p.token == ast.KindReadonlyKeyword || p.token == ast.KindPlusToken || p.token == ast.KindMinusToken { @@ -3146,7 +3147,9 @@ func (p *Parser) parseMappedType() *ast.Node { p.parseSemicolon() members := p.parseList(PCTypeMembers, (*Parser).parseTypeMember) p.parseExpected(ast.KindCloseBraceToken) - return p.finishNode(p.factory.NewMappedTypeNode(readonlyToken, typeParameter, nameType, questionToken, typeNode, members), pos) + result := p.finishNode(p.factory.NewMappedTypeNode(readonlyToken, typeParameter, nameType, questionToken, typeNode, members), pos) + p.withJSDoc(result, hasJSDoc) + return result } func (p *Parser) parseMappedTypeParameter() *ast.Node { diff --git a/internal/parser/utilities.go b/internal/parser/utilities.go index 2e143a835b8..0336d51b771 100644 --- a/internal/parser/utilities.go +++ b/internal/parser/utilities.go @@ -27,7 +27,7 @@ func tokenIsIdentifierOrKeywordOrGreaterThan(token ast.Kind) bool { func GetJSDocCommentRanges(f *ast.NodeFactory, commentRanges []ast.CommentRange, node *ast.Node, text string) []ast.CommentRange { switch node.Kind { - case ast.KindParameter, ast.KindTypeParameter, ast.KindFunctionExpression, ast.KindArrowFunction, ast.KindParenthesizedExpression, ast.KindVariableDeclaration, ast.KindExportSpecifier: + case ast.KindParameter, ast.KindTypeParameter, ast.KindFunctionExpression, ast.KindArrowFunction, ast.KindParenthesizedExpression, ast.KindVariableDeclaration, ast.KindExportSpecifier, ast.KindMappedType: for commentRange := range scanner.GetTrailingCommentRanges(f, text, node.Pos()) { commentRanges = append(commentRanges, commentRange) } diff --git a/testdata/baselines/reference/fourslash/goToDefinition/goToDefinitionMappedType2.baseline.jsonc b/testdata/baselines/reference/fourslash/goToDefinition/goToDefinitionMappedType2.baseline.jsonc new file mode 100644 index 00000000000..94a2611d3b8 --- /dev/null +++ b/testdata/baselines/reference/fourslash/goToDefinition/goToDefinitionMappedType2.baseline.jsonc @@ -0,0 +1,15 @@ +// === goToDefinition === +// === /goToDefinitionMappedType2.ts === +// interface Foo { +// <|[|property|]: string|> +// } +// +// type JustMapIt = {[P in keyof T]: 0} +// --- (line: 6) skipped --- + +// --- (line: 11) skipped --- +// +// { +// let gotoDef!: MapItWithRemap +// gotoDef./*GOTO DEF*/mapped_property +// } \ No newline at end of file diff --git a/testdata/baselines/reference/fourslash/goToDefinition/goToDefinitionMappedType3.baseline.jsonc b/testdata/baselines/reference/fourslash/goToDefinition/goToDefinitionMappedType3.baseline.jsonc new file mode 100644 index 00000000000..0a7e194de51 --- /dev/null +++ b/testdata/baselines/reference/fourslash/goToDefinition/goToDefinitionMappedType3.baseline.jsonc @@ -0,0 +1,14 @@ +// === goToDefinition === +// === /goToDefinitionMappedType3.ts === +// interface Source { +// <|[|alpha|]: number;|> +// beta: string; +// } +// +// --- (line: 6) skipped --- + +// --- (line: 21) skipped --- +// betaSuffix: () => "hello", +// }; +// +// obj./*GOTO DEF*/alphaSuffix(); \ No newline at end of file diff --git a/testdata/baselines/reference/fourslash/quickInfo/quickInfoMappedType3.baseline b/testdata/baselines/reference/fourslash/quickInfo/quickInfoMappedType3.baseline new file mode 100644 index 00000000000..bfa27911003 --- /dev/null +++ b/testdata/baselines/reference/fourslash/quickInfo/quickInfoMappedType3.baseline @@ -0,0 +1,190 @@ +// === QuickInfo === +=== /quickInfoMappedType3.ts === +// type Getters = /** @inheritDoc desc on Getters */ { +// [Property in keyof Type as `get${Capitalize< +// string & Property +// >}`]: () => Type[Property]; +// }; +// +// interface Person { +// // ✅ When hovering here, the documentation is displayed, as it should. +// /** +// * Person's name. +// * @example "John Doe" +// */ +// name: string; +// +// // ✅ When hovering here, the documentation is displayed, as it should. +// /** +// * Person's Age. +// * @example 30 +// */ +// age: number; +// +// // ✅ When hovering here, the documentation is displayed, as it should. +// /** +// * Person's Location. +// * @example "Brazil" +// */ +// location: string; +// } +// +// type LazyPerson = Getters; +// +// const me: LazyPerson = { +// // ❌ When hovering here, the documentation is NOT displayed. +// getName: () => "Jake Carter", +// ^^^^^^^ +// | ---------------------------------------------------------------------- +// | ```tsx +// | (property) getName: () => string +// | ``` +// | +// | +// | *@inheritDoc* — desc on Getters +// | ---------------------------------------------------------------------- +// // ❌ When hovering here, the documentation is NOT displayed. +// getAge: () => 35, +// ^^^^^^ +// | ---------------------------------------------------------------------- +// | ```tsx +// | (property) getAge: () => number +// | ``` +// | +// | +// | *@inheritDoc* — desc on Getters +// | ---------------------------------------------------------------------- +// // ❌ When hovering here, the documentation is NOT displayed. +// getLocation: () => "United States", +// ^^^^^^^^^^^ +// | ---------------------------------------------------------------------- +// | ```tsx +// | (property) getLocation: () => string +// | ``` +// | +// | +// | *@inheritDoc* — desc on Getters +// | ---------------------------------------------------------------------- +// }; +// +// // ❌ When hovering here, the documentation is NOT displayed. +// me.getName(); +// ^^^^^^^ +// | ---------------------------------------------------------------------- +// | ```tsx +// | (property) getName: () => string +// | ``` +// | +// | +// | *@inheritDoc* — desc on Getters +// | ---------------------------------------------------------------------- +[ + { + "marker": { + "Position": 755, + "LSPosition": { + "line": 33, + "character": 2 + }, + "Name": "1", + "Data": {} + }, + "item": { + "contents": { + "kind": "markdown", + "value": "```tsx\n(property) getName: () => string\n```\n\n\n*@inheritDoc* — desc on Getters " + }, + "range": { + "start": { + "line": 33, + "character": 2 + }, + "end": { + "line": 33, + "character": 9 + } + } + } + }, + { + "marker": { + "Position": 852, + "LSPosition": { + "line": 35, + "character": 2 + }, + "Name": "2", + "Data": {} + }, + "item": { + "contents": { + "kind": "markdown", + "value": "```tsx\n(property) getAge: () => number\n```\n\n\n*@inheritDoc* — desc on Getters " + }, + "range": { + "start": { + "line": 35, + "character": 2 + }, + "end": { + "line": 35, + "character": 8 + } + } + } + }, + { + "marker": { + "Position": 937, + "LSPosition": { + "line": 37, + "character": 2 + }, + "Name": "3", + "Data": {} + }, + "item": { + "contents": { + "kind": "markdown", + "value": "```tsx\n(property) getLocation: () => string\n```\n\n\n*@inheritDoc* — desc on Getters " + }, + "range": { + "start": { + "line": 37, + "character": 2 + }, + "end": { + "line": 37, + "character": 13 + } + } + } + }, + { + "marker": { + "Position": 1043, + "LSPosition": { + "line": 41, + "character": 3 + }, + "Name": "4", + "Data": {} + }, + "item": { + "contents": { + "kind": "markdown", + "value": "```tsx\n(property) getName: () => string\n```\n\n\n*@inheritDoc* — desc on Getters " + }, + "range": { + "start": { + "line": 41, + "character": 3 + }, + "end": { + "line": 41, + "character": 10 + } + } + } + } +] \ No newline at end of file