Skip to content

Commit 6b6e555

Browse files
authored
feat(playground): add inspector panel for AST/Type/Symbol/Signature/Flow info (#448)
1 parent f5a27b8 commit 6b6e555

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

51 files changed

+7352
-95
lines changed

cmd/rslint/api.go

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package main
22

33
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
47
"fmt"
58
"os"
69
"sort"
@@ -16,6 +19,7 @@ import (
1619
"github.com/microsoft/typescript-go/shim/vfs/osvfs"
1720
api "github.com/web-infra-dev/rslint/internal/api"
1821
rslintconfig "github.com/web-infra-dev/rslint/internal/config"
22+
"github.com/web-infra-dev/rslint/internal/inspector"
1923
"github.com/web-infra-dev/rslint/internal/linter"
2024
"github.com/web-infra-dev/rslint/internal/rule"
2125
"github.com/web-infra-dev/rslint/internal/utils"
@@ -24,6 +28,18 @@ import (
2428
// IPCHandler implements the ipc.Handler interface
2529
type IPCHandler struct{}
2630

31+
// programCache holds a cached Program instance for AST info requests
32+
type programCache struct {
33+
mu sync.RWMutex
34+
fileContent string
35+
compilerOptions string // JSON serialized for comparison
36+
program *compiler.Program
37+
sourceFile *ast.SourceFile
38+
}
39+
40+
// Global program cache for AST info requests
41+
var astInfoProgramCache = &programCache{}
42+
2743
// HandleLint handles lint requests in IPC mode
2844
func (h *IPCHandler) HandleLint(req api.LintRequest) (*api.LintResponse, error) {
2945

@@ -328,6 +344,191 @@ func (h *IPCHandler) HandleApplyFixes(req api.ApplyFixesRequest) (*api.ApplyFixe
328344
}, nil
329345
}
330346

347+
// HandleGetAstInfo handles get AST info requests in IPC mode
348+
func (h *IPCHandler) HandleGetAstInfo(req api.GetAstInfoRequest) (*api.GetAstInfoResponse, error) {
349+
// Fixed user file name for program creation
350+
const userFileName = "/index.ts"
351+
352+
// Serialize compiler options for comparison
353+
compilerOptionsJSON := "{}"
354+
if req.CompilerOptions != nil {
355+
jsonBytes, err := json.Marshal(req.CompilerOptions)
356+
if err != nil {
357+
return nil, fmt.Errorf("failed to marshal compiler options: %w", err)
358+
}
359+
compilerOptionsJSON = string(jsonBytes)
360+
}
361+
362+
// Check if we can use cached program
363+
program, userSourceFile := getCachedProgram(req.FileContent, compilerOptionsJSON)
364+
if program == nil || userSourceFile == nil {
365+
// Cache miss - create new program
366+
var err error
367+
program, userSourceFile, err = createAndCacheProgram(userFileName, req.FileContent, compilerOptionsJSON, req.CompilerOptions)
368+
if err != nil {
369+
return nil, err
370+
}
371+
}
372+
373+
// Get type checker
374+
typeChecker, done := program.GetTypeChecker(context.Background())
375+
defer done()
376+
377+
// Determine which source file to query
378+
// If FileName is set to an external file, query that file (e.g., lib.d.ts)
379+
// Otherwise, query the user's source file
380+
var targetSourceFile *ast.SourceFile
381+
if req.FileName != "" && req.FileName != userFileName {
382+
targetSourceFile = program.GetSourceFile(req.FileName)
383+
if targetSourceFile == nil {
384+
return &api.GetAstInfoResponse{}, nil
385+
}
386+
} else {
387+
targetSourceFile = userSourceFile
388+
}
389+
390+
isExternalFile := targetSourceFile != userSourceFile
391+
392+
// Build the response
393+
// Use userSourceFile as the "current" file for the builder
394+
// This determines which files are considered "external" (fileName will be set for nodes not in userSourceFile)
395+
builder := api.NewAstInfoBuilder(typeChecker, userSourceFile)
396+
response := &api.GetAstInfoResponse{}
397+
398+
// Special case: if requesting SourceFile by kind, build it directly without Node conversion
399+
if req.Kind > 0 && ast.Kind(req.Kind) == ast.KindSourceFile {
400+
response.Node = builder.BuildSourceFileNodeInfo(targetSourceFile)
401+
// SourceFile doesn't have type/symbol/signature/flow, so return early
402+
return response, nil
403+
}
404+
405+
// Find the node at the specified position (with optional end for exact matching)
406+
node := inspector.FindNodeAtPosition(targetSourceFile, req.Position, req.End, req.Kind)
407+
if node == nil {
408+
return &api.GetAstInfoResponse{}, nil
409+
}
410+
411+
// Build node info
412+
response.Node = builder.BuildNodeInfo(node)
413+
414+
// Build type info
415+
t := inspector.GetTypeAtNode(typeChecker, node)
416+
if t != nil {
417+
response.Type = builder.BuildTypeInfo(t)
418+
}
419+
420+
// Build symbol info
421+
// First try to get symbol directly from node
422+
symbol := typeChecker.GetSymbolAtLocation(node)
423+
// If no symbol at node, try to get it from the type
424+
if symbol == nil && t != nil {
425+
symbol = t.Symbol()
426+
}
427+
if symbol != nil {
428+
response.Symbol = builder.BuildSymbolInfo(symbol)
429+
}
430+
431+
// Build signature info
432+
sig := inspector.GetSignatureOfNode(typeChecker, node)
433+
if sig != nil {
434+
response.Signature = builder.BuildSignatureInfo(sig)
435+
}
436+
437+
// Build flow info (only for nodes in user's source file)
438+
if !isExternalFile {
439+
flowNode := inspector.GetFlowNodeOfNode(node)
440+
if flowNode != nil {
441+
response.Flow = builder.BuildFlowInfo(flowNode)
442+
}
443+
}
444+
445+
return response, nil
446+
}
447+
448+
// getCachedProgram returns the cached program if it matches the current request
449+
func getCachedProgram(fileContent, compilerOptionsJSON string) (*compiler.Program, *ast.SourceFile) {
450+
astInfoProgramCache.mu.RLock()
451+
defer astInfoProgramCache.mu.RUnlock()
452+
453+
if astInfoProgramCache.program == nil {
454+
return nil, nil
455+
}
456+
457+
// Check if cache is valid (only fileContent and compilerOptions matter)
458+
if astInfoProgramCache.fileContent == fileContent &&
459+
astInfoProgramCache.compilerOptions == compilerOptionsJSON {
460+
return astInfoProgramCache.program, astInfoProgramCache.sourceFile
461+
}
462+
463+
return nil, nil
464+
}
465+
466+
// createAndCacheProgram creates a new program and caches it
467+
func createAndCacheProgram(fileName, fileContent, compilerOptionsJSON string, compilerOptions map[string]any) (*compiler.Program, *ast.SourceFile, error) {
468+
// Create a virtual filesystem with the provided file content
469+
fs := bundled.WrapFS(cachedvfs.From(osvfs.FS()))
470+
471+
fileContents := map[string]string{
472+
fileName: fileContent,
473+
}
474+
fs = utils.NewOverlayVFS(fs, fileContents)
475+
476+
// Build tsconfig from request options or use defaults
477+
tsconfigContent := buildTsConfigContent(fileName, compilerOptions)
478+
tsconfigPath := "/tsconfig.json"
479+
fs = utils.NewOverlayVFS(fs, map[string]string{
480+
tsconfigPath: tsconfigContent,
481+
})
482+
483+
// Create compiler host and program
484+
host := utils.CreateCompilerHost("/", fs)
485+
program, err := utils.CreateProgram(false, fs, "/", tsconfigPath, host)
486+
if err != nil {
487+
return nil, nil, fmt.Errorf("failed to create program: %w", err)
488+
}
489+
490+
// Get the source file
491+
sourceFile := program.GetSourceFile(fileName)
492+
if sourceFile == nil {
493+
return nil, nil, errors.New("failed to get source file")
494+
}
495+
496+
// Update cache
497+
astInfoProgramCache.mu.Lock()
498+
astInfoProgramCache.fileContent = fileContent
499+
astInfoProgramCache.compilerOptions = compilerOptionsJSON
500+
astInfoProgramCache.program = program
501+
astInfoProgramCache.sourceFile = sourceFile
502+
astInfoProgramCache.mu.Unlock()
503+
504+
return program, sourceFile, nil
505+
}
506+
507+
// buildTsConfigContent creates a tsconfig.json content string from compiler options
508+
func buildTsConfigContent(fileName string, compilerOptions map[string]any) string {
509+
// Default compiler options
510+
opts := map[string]any{
511+
"target": "ESNext",
512+
"module": "ESNext",
513+
"strict": true,
514+
"strictNullChecks": true,
515+
}
516+
517+
// Merge with provided options (provided options override defaults)
518+
for k, v := range compilerOptions {
519+
opts[k] = v
520+
}
521+
522+
// Serialize compiler options to JSON
523+
optsJSON, err := json.Marshal(opts)
524+
if err != nil {
525+
// Fallback to minimal config on error
526+
return fmt.Sprintf(`{"compilerOptions":{"target":"ESNext","module":"ESNext","strict":true},"files":["%s"]}`, fileName)
527+
}
528+
529+
return fmt.Sprintf(`{"compilerOptions":%s,"files":["%s"]}`, string(optsJSON), fileName)
530+
}
531+
331532
// runAPI runs the linter in IPC mode
332533
func runAPI() int {
333534
handler := &IPCHandler{}

internal/api/api.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,32 @@ import (
1313

1414
"github.com/microsoft/typescript-go/shim/api/encoder"
1515
"github.com/microsoft/typescript-go/shim/ast"
16+
"github.com/microsoft/typescript-go/shim/checker"
17+
"github.com/web-infra-dev/rslint/internal/inspector"
1618
)
1719

20+
// Re-export types from inspector package for backward compatibility
21+
type (
22+
GetAstInfoRequest = inspector.GetAstInfoRequest
23+
GetAstInfoResponse = inspector.GetAstInfoResponse
24+
NodeInfo = inspector.NodeInfo
25+
NodeListMeta = inspector.NodeListMeta
26+
TypeInfo = inspector.TypeInfo
27+
IndexInfoType = inspector.IndexInfoType
28+
SymbolInfo = inspector.SymbolInfo
29+
SignatureInfo = inspector.SignatureInfo
30+
TypePredicateInfo = inspector.TypePredicateInfo
31+
FlowInfo = inspector.FlowInfo
32+
)
33+
34+
// AstInfoBuilder wraps inspector.Builder for backward compatibility
35+
type AstInfoBuilder = inspector.Builder
36+
37+
// NewAstInfoBuilder creates a new AST info builder (backward compatible)
38+
func NewAstInfoBuilder(c *checker.Checker, sf *ast.SourceFile) *AstInfoBuilder {
39+
return inspector.NewBuilder(c, sf)
40+
}
41+
1842
// Protocol implements a binary message protocol similar to esbuild:
1943
// - First 4 bytes: message length (uint32 in little endian)
2044
// - Next N bytes: JSON message content
@@ -27,6 +51,8 @@ const (
2751
KindLint MessageKind = "lint"
2852
// KindApplyFixes is sent from JS to Go to request applying fixes
2953
KindApplyFixes MessageKind = "applyFixes"
54+
// KindGetAstInfo is sent from JS to Go to request AST info at a position
55+
KindGetAstInfo MessageKind = "getAstInfo"
3056
// KindResponse is sent from Go to JS with the lint results
3157
KindResponse MessageKind = "response"
3258
// KindError is sent when an error occurs
@@ -166,6 +192,7 @@ type Fix struct {
166192
type Handler interface {
167193
HandleLint(req LintRequest) (*LintResponse, error)
168194
HandleApplyFixes(req ApplyFixesRequest) (*ApplyFixesResponse, error)
195+
HandleGetAstInfo(req GetAstInfoRequest) (*GetAstInfoResponse, error)
169196
}
170197

171198
// Service manages the IPC communication
@@ -250,6 +277,8 @@ func (s *Service) Start() error {
250277
s.handleLint(msg)
251278
case KindApplyFixes:
252279
s.handleApplyFixes(msg)
280+
case KindGetAstInfo:
281+
s.handleGetAstInfo(msg)
253282
case KindExit:
254283
s.handleExit(msg)
255284
return nil
@@ -330,6 +359,29 @@ func (s *Service) handleApplyFixes(msg *Message) {
330359
s.sendResponse(msg.ID, resp)
331360
}
332361

362+
// handleGetAstInfo handles get AST info messages
363+
func (s *Service) handleGetAstInfo(msg *Message) {
364+
var req GetAstInfoRequest
365+
data, err := json.Marshal(msg.Data)
366+
if err != nil {
367+
s.sendError(msg.ID, fmt.Sprintf("failed to marshal data: %v", err))
368+
return
369+
}
370+
371+
if err := json.Unmarshal(data, &req); err != nil {
372+
s.sendError(msg.ID, fmt.Sprintf("failed to parse get ast info request: %v", err))
373+
return
374+
}
375+
376+
resp, err := s.handler.HandleGetAstInfo(req)
377+
if err != nil {
378+
s.sendError(msg.ID, err.Error())
379+
return
380+
}
381+
382+
s.sendResponse(msg.ID, resp)
383+
}
384+
333385
// sendResponse sends a response message
334386
func (s *Service) sendResponse(id int, data interface{}) {
335387
msg := &Message{

0 commit comments

Comments
 (0)