diff --git a/.cursor/worktrees.json b/.cursor/worktrees.json new file mode 100644 index 00000000..f862bfd4 --- /dev/null +++ b/.cursor/worktrees.json @@ -0,0 +1,7 @@ +{ + "setup-worktree": [ + "# fnm use", + "# npm install", + "# cp $ROOT_WORKTREE_PATH/.env .env" + ] +} diff --git a/languageserver/cmd/languageserver/languageserver b/languageserver/cmd/languageserver/languageserver new file mode 100755 index 00000000..c9edb7f8 Binary files /dev/null and b/languageserver/cmd/languageserver/languageserver differ diff --git a/languageserver/cmd/languageserver/languageserver.wasm b/languageserver/cmd/languageserver/languageserver.wasm new file mode 100755 index 00000000..8a621ce1 Binary files /dev/null and b/languageserver/cmd/languageserver/languageserver.wasm differ diff --git a/languageserver/integration/config_manager.go b/languageserver/integration/config_manager.go index de55372c..166e7865 100644 --- a/languageserver/integration/config_manager.go +++ b/languageserver/integration/config_manager.go @@ -19,7 +19,8 @@ package integration import ( - "encoding/json" + "errors" + "fmt" "path/filepath" "strings" "sync" @@ -64,23 +65,50 @@ type ConfigManager struct { // loadErrors keeps the last load/reload error per config path (abs) loadErrors map[string]string + + // onFileChanged, if set, is called with the absolute path of a changed .cdc file + onFileChanged func(string) + + // onProjectFilesChanged, if set, is called with the config path when .cdc files are created/deleted + // This allows re-checking all open files in the project, not just dependents + onProjectFilesChanged func(string) + + // single-flight guards for state loads keyed by abs cfg path + sfMu sync.Mutex + stateLoadInFlight map[string]chan struct{} } func NewConfigManager(loader flowkit.ReaderWriter, enableFlowClient bool, numberOfAccounts int, initConfigPath string) *ConfigManager { return &ConfigManager{ - loader: loader, - enableFlowClient: enableFlowClient, - numberOfAccounts: numberOfAccounts, - initConfigPath: initConfigPath, - states: make(map[string]flowState), - clients: make(map[string]flowClient), - watchers: make(map[string]*fsnotify.Watcher), - docToConfig: make(map[string]string), - dirWatchers: make(map[string]*fsnotify.Watcher), - loadErrors: make(map[string]string), + loader: loader, + enableFlowClient: enableFlowClient, + numberOfAccounts: numberOfAccounts, + initConfigPath: initConfigPath, + states: make(map[string]flowState), + clients: make(map[string]flowClient), + watchers: make(map[string]*fsnotify.Watcher), + docToConfig: make(map[string]string), + dirWatchers: make(map[string]*fsnotify.Watcher), + loadErrors: make(map[string]string), + stateLoadInFlight: make(map[string]chan struct{}), } } +// SetOnFileChanged registers a callback for .cdc file changes under watched directories. +func (m *ConfigManager) SetOnFileChanged(cb func(string)) { + m.mu.Lock() + defer m.mu.Unlock() + m.onFileChanged = cb +} + +// SetOnProjectFilesChanged registers a callback for when .cdc files are created/deleted in a project. +// The callback receives the absolute path to the project's flow.json. +func (m *ConfigManager) SetOnProjectFilesChanged(cb func(string)) { + m.mu.Lock() + defer m.mu.Unlock() + m.onProjectFilesChanged = cb +} + // ResolveStateForChecker returns the state associated with the closest flow.json for the given checker. func (m *ConfigManager) ResolveStateForChecker(checker *sema.Checker) (flowState, error) { if checker == nil || checker.Location == nil { @@ -221,8 +249,8 @@ func (m *ConfigManager) findNearestFlowJSON(filePath string) string { p := cleanWindowsPath(filePath) dir := filepath.Dir(p) prev := "" - for dir != prev { - candidate := filepath.Join(dir, flowConfigFilename) + for dir != prev { + candidate := filepath.Join(dir, flowConfigFilename) // Use loader to check for existence if _, err := m.loader.Stat(candidate); err == nil { return candidate @@ -274,10 +302,18 @@ func (m *ConfigManager) ConfigPathForProject(projectID string) string { if err != nil { return cfgPath } - // If a directory was provided, prefer flow.json within it - if filepath.Base(absCfgPath) != flowConfigFilename { - candidate := filepath.Join(absCfgPath, flowConfigFilename) + // Canonicalize symlinks for consistent identity across /var vs /private/var on macOS + if real, err := filepath.EvalSymlinks(absCfgPath); err == nil { + absCfgPath = real + } + // If a directory was provided, prefer flow.json within it + if filepath.Base(absCfgPath) != flowConfigFilename { + candidate := filepath.Join(absCfgPath, flowConfigFilename) if _, err := m.loader.Stat(candidate); err == nil { + // Return canonicalized candidate path as well + if real, err := filepath.EvalSymlinks(candidate); err == nil { + return real + } return candidate } } @@ -292,6 +328,13 @@ func (m *ConfigManager) IsPathInProject(projectID string, absPath string) bool { } absRoot, _ := filepath.Abs(filepath.Dir(cfgPath)) absFile, _ := filepath.Abs(absPath) + // Canonicalize both sides to avoid false negatives due to symlinks + if real, err := filepath.EvalSymlinks(absRoot); err == nil { + absRoot = real + } + if real, err := filepath.EvalSymlinks(absFile); err == nil { + absFile = real + } if rel, err := filepath.Rel(absRoot, absFile); err == nil { return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) } @@ -309,36 +352,15 @@ func (m *ConfigManager) IsSameProject(projectID string, absPath string) bool { return true } absDst, _ := filepath.Abs(cleanWindowsPath(dst)) - return absDst == cfgPath -} - -// GetContractSourceForProject reads the project's flow.json and returns the code for the given contract name if mapped -func (m *ConfigManager) GetContractSourceForProject(projectID string, name string) (string, error) { - cfgPath := m.ConfigPathForProject(projectID) - if cfgPath == "" || name == "" { - return "", nil - } - data, err := m.loader.ReadFile(cfgPath) - if err != nil { - return "", err + // Canonicalize both config paths for stable comparison + absCfg := cfgPath + if real, err := filepath.EvalSymlinks(absDst); err == nil { + absDst = real } - var parsed struct { - Contracts map[string]string `json:"contracts"` + if real, err := filepath.EvalSymlinks(absCfg); err == nil { + absCfg = real } - if err := json.Unmarshal(data, &parsed); err != nil { - return "", err - } - rel, ok := parsed.Contracts[name] - if !ok || rel == "" { - return "", nil - } - dir := filepath.Dir(cfgPath) - path := filepath.Join(dir, rel) - code, err := m.loader.ReadFile(path) - if err != nil { - return "", err - } - return string(code), nil + return absDst == absCfg } // ResolveStateForProject returns the state associated with the given project ID (flow.json path) @@ -349,7 +371,7 @@ func (m *ConfigManager) ResolveStateForProject(projectID string) (flowState, err fallback := m.lastUsedConfigPath m.mu.RUnlock() if fallback != "" { - return m.loadState(fallback) + return m.loadStateSingleFlight(fallback) } return nil, nil } @@ -361,13 +383,53 @@ func (m *ConfigManager) ResolveStateForProject(projectID string) (flowState, err m.setLastUsed(absCfgPath) return st, nil } - st, err := m.loadState(absCfgPath) + st, err := m.loadStateSingleFlight(absCfgPath) if err == nil && st != nil { m.setLastUsed(absCfgPath) } return st, err } +// loadStateSingleFlight ensures only one goroutine loads state for cfgPath at a time. +func (m *ConfigManager) loadStateSingleFlight(cfgPath string) (flowState, error) { + absCfgPath, err := filepath.Abs(cleanWindowsPath(cfgPath)) + if err != nil { + return nil, err + } + m.sfMu.Lock() + if ch, ok := m.stateLoadInFlight[absCfgPath]; ok { + // Another goroutine is loading; wait + m.sfMu.Unlock() + <-ch + // After load completes, return existing (or nil) state + m.mu.RLock() + st := m.states[absCfgPath] + m.mu.RUnlock() + if st != nil && st.IsLoaded() { + return st, nil + } + // Return last error if captured + if msg := m.loadErrors[absCfgPath]; msg != "" { + return nil, errors.New(msg) + } + return nil, fmt.Errorf("failed to load state for %s", absCfgPath) + } + ch := make(chan struct{}) + m.stateLoadInFlight[absCfgPath] = ch + m.sfMu.Unlock() + + // Perform the load + st, loadErr := m.loadState(absCfgPath) + + // Signal completion + m.sfMu.Lock() + delete(m.stateLoadInFlight, absCfgPath) + close(ch) + m.sfMu.Unlock() + + return st, loadErr +} + // ResolveClientForProject returns the Flow client for the given project ID (flow.json path) func (m *ConfigManager) ResolveClientForProject(projectID string) (flowClient, error) { if !m.enableFlowClient { @@ -529,10 +591,10 @@ func (m *ConfigManager) watchLoop(cfgPath string, watcher *fsnotify.Watcher) { debounce.Reset(debounceWindow) continue } - // Handle rename/create of flow.json in the same directory (atomic save semantics) + // Handle rename/create of flow.json in the same directory (atomic save semantics) dir := filepath.Dir(cfgPath) - if filepath.Dir(ev.Name) == dir && filepath.Base(ev.Name) == flowConfigFilename { - newCfg := filepath.Join(dir, flowConfigFilename) + if filepath.Dir(ev.Name) == dir && filepath.Base(ev.Name) == flowConfigFilename { + newCfg := filepath.Join(dir, flowConfigFilename) if newCfg != cfgPath { m.mu.Lock() // Move state/client to new key if present @@ -553,6 +615,43 @@ func (m *ConfigManager) watchLoop(cfgPath string, watcher *fsnotify.Watcher) { _ = watcher.Add(newCfg) debounce.Reset(debounceWindow) } + continue + } + // Detect .cdc file changes in the config directory and notify + if filepath.Dir(ev.Name) == dir && strings.HasSuffix(strings.ToLower(ev.Name), ".cdc") { + abs := ev.Name + if a, err := filepath.Abs(abs); err == nil { + abs = a + } + // For remove/delete events, the file may not exist, so skip symlink resolution + if ev.Op&fsnotify.Remove == 0 { + if real, err := filepath.EvalSymlinks(abs); err == nil { + abs = real + } + } + // On Create/Remove, trigger project-wide re-check without reloading state + // The import resolver will naturally fail when trying to read a deleted file + if ev.Op&(fsnotify.Create|fsnotify.Remove) != 0 { + m.mu.RLock() + projectCb := m.onProjectFilesChanged + m.mu.RUnlock() + + // Trigger project-wide re-check (all open files might have failed imports) + // Don't reload state here - let the import resolver discover missing files naturally + if projectCb != nil { + go projectCb(cfgPath) + } + } + + m.mu.RLock() + cb := m.onFileChanged + m.mu.RUnlock() + if cb != nil { + // Notify on write/create/rename/remove events for dependency tracking + if ev.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename|fsnotify.Remove) != 0 { + go cb(abs) + } + } } case <-debounce.C: // Reload state and client @@ -624,14 +723,14 @@ func (m *ConfigManager) watchDirLoop(dir string, watcher *fsnotify.Watcher) { if !ok { return } - // Only react to flow.json in this directory + // Only react to flow.json in this directory base := filepath.Base(ev.Name) - if base != flowConfigFilename || filepath.Dir(ev.Name) != dir { + if base != flowConfigFilename || filepath.Dir(ev.Name) != dir { continue } debounce.Reset(debounceWindow) case <-debounce.C: - cfgPath := filepath.Join(dir, flowConfigFilename) + cfgPath := filepath.Join(dir, flowConfigFilename) // If file exists now, reload state/client and attach file watcher if _, err := m.loader.Stat(cfgPath); err == nil { m.mu.RLock() diff --git a/languageserver/integration/integration.go b/languageserver/integration/integration.go index 599c5baa..ac3fc74c 100644 --- a/languageserver/integration/integration.go +++ b/languageserver/integration/integration.go @@ -24,18 +24,17 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "strconv" + "strings" "sync" "sync/atomic" + "github.com/onflow/cadence/common" "github.com/onflow/cadence/sema" - "github.com/onflow/flowkit/v2" "github.com/spf13/afero" - "path/filepath" - "strings" - "github.com/onflow/cadence-tools/languageserver/protocol" "github.com/onflow/cadence-tools/languageserver/server" ) @@ -62,7 +61,8 @@ func (i *FlowIntegration) didOpenInitHook(s *server.Server) func(protocol.Conn, // Do not validate init-config here; rely on ConfigManager load errors instead if cfg := i.cfgManager.NearestConfigPath(path); cfg != "" { // Attempt to load; if flowkit fails, remember and show the error and skip prompt - if _, err := i.cfgManager.ResolveStateForPath(cfg); err != nil { + // Use ResolveStateForProject since cfg is already a config path (flow.json) + if _, err := i.cfgManager.ResolveStateForProject(cfg); err != nil { conn.ShowMessage(&protocol.ShowMessageParams{ Type: protocol.Error, Message: fmt.Sprintf("Failed to load flow.json: %s", err.Error()), @@ -84,8 +84,10 @@ func (i *FlowIntegration) didOpenInitHook(s *server.Server) func(protocol.Conn, return } dir := filepath.Dir(path) - if root := s.WorkspaceFolderRootForPath(path); root != "" { - dir = root + if s != nil { + if root := s.WorkspaceFolderRootForPath(path); root != "" { + dir = root + } } // If we've already prompted for this root in this session, skip skip := false @@ -140,7 +142,7 @@ func (i *FlowIntegration) didOpenInitHook(s *server.Server) func(protocol.Conn, } } -func NewFlowIntegration(s *server.Server, enableFlowClient bool) (*FlowIntegration, error) { +func NewFlowIntegration(s *server.Server, enableFlowClient bool) (*FlowIntegration, []server.Option, error) { loader := &afero.Afero{Fs: afero.NewOsFs()} state := newFlowkitState(loader) @@ -154,11 +156,20 @@ func NewFlowIntegration(s *server.Server, enableFlowClient bool) (*FlowIntegrati invalidWarnedRoots: make(map[string]struct{}), } - // Always create a config manager so per-file config discovery works even without init options + // Setup config manager with file change handlers that call into server's FileWatcher integration.cfgManager = NewConfigManager(loader, enableFlowClient, 0, "") + if s != nil && s.FileWatcher() != nil { + fw := s.FileWatcher() + integration.cfgManager.SetOnFileChanged(func(absPath string) { + fw.HandleFileChanged(absPath) + }) + integration.cfgManager.SetOnProjectFilesChanged(func(cfgPath string) { + fw.HandleProjectFilesChanged(cfgPath) + }) + } - // Provide a project identity provider keyed by nearest flow.json for checker cache scoping - projectProvider := projectIdentityProvider{cfg: integration.cfgManager} + // Provide a project resolver for identity and location canonicalization + projectResolver := flowProjectResolver{cfg: integration.cfgManager} resolve := resolvers{ loader: loader, @@ -171,7 +182,7 @@ func NewFlowIntegration(s *server.Server, enableFlowClient bool) (*FlowIntegrati server.WithInitializationOptionsHandler(integration.initialize), server.WithExtendedStandardLibraryValues(FVMStandardLibraryValues()...), server.WithIdentifierImportResolver(resolve.identifierImportProject), - server.WithProjectIdentityProvider(projectProvider), + server.WithProjectResolver(projectResolver), } // Prompt to create flow.json when opening an existing .cdc file without a config. @@ -199,12 +210,7 @@ func NewFlowIntegration(s *server.Server, enableFlowClient bool) (*FlowIntegrati } } - err := s.SetOptions(options...) - if err != nil { - return nil, err - } - - return integration, nil + return integration, options, nil } type FlowIntegration struct { @@ -223,11 +229,11 @@ type FlowIntegration struct { invalidWarnedRoots map[string]struct{} } -// projectIdentityProvider implements server.ProjectIdentityProvider using ConfigManager. -// It returns the absolute flow.json path as the project ID, or empty if none is found. -type projectIdentityProvider struct{ cfg *ConfigManager } +// flowProjectResolver implements server.ProjectResolver +type flowProjectResolver struct{ cfg *ConfigManager } -func (p projectIdentityProvider) ProjectIDForURI(uri protocol.DocumentURI) string { +// ProjectIDForURI returns the project ID for a given URI +func (p flowProjectResolver) ProjectIDForURI(uri protocol.DocumentURI) string { if p.cfg == nil { return "" } @@ -235,31 +241,82 @@ func (p projectIdentityProvider) ProjectIDForURI(uri protocol.DocumentURI) strin if p.cfg.initConfigPath != "" { return stableProjectID(p.cfg.loader, p.cfg.initConfigPath) } - u := string(uri) - var path string - if strings.HasPrefix(u, "file://") { - // Decode URI and normalize Windows paths (handles %20, etc.) - path = deURI(cleanWindowsPath(strings.TrimPrefix(u, "file://"))) - } else { - // Assume raw filesystem path - path = deURI(cleanWindowsPath(u)) + // Otherwise, find the nearest flow.json and use its path (with mtime suffix) + path := deURI(cleanWindowsPath(strings.TrimPrefix(string(uri), "file://"))) + if abs, err := filepath.Abs(path); err == nil { + path = abs } - cfgPath := p.cfg.NearestConfigPath(path) - // If no on-disk config is found, but an init-config override exists, use it for project scoping - if cfgPath == "" && p.cfg.initConfigPath != "" { - cfgPath = p.cfg.initConfigPath + if real, err := filepath.EvalSymlinks(path); err == nil { + path = real } - if cfgPath == "" { - if abs, err := filepath.Abs(path); err == nil { - return filepath.Dir(abs) - } - return filepath.Dir(path) + if cfgPath := p.cfg.NearestConfigPath(path); cfgPath != "" { + // Ensure state is loaded for this project synchronously so downstream + // canonicalization can rely on cache/state without races. + _, _ = p.cfg.ResolveStateForProject(cfgPath) + return stableProjectID(p.cfg.loader, cfgPath) } - // Normalize to absolute path for stability and include modtime to bust global cache on config edits - if abs, err := filepath.Abs(cfgPath); err == nil { - cfgPath = abs + return "" +} + +// CanonicalLocation returns a canonical file-backed location for string imports +func (p flowProjectResolver) CanonicalLocation(projectID string, location common.Location) common.Location { + if p.cfg == nil { + return location + } + switch loc := location.(type) { + case common.StringLocation: + name := string(loc) + // If it looks like a file path, normalize to absolute path + if strings.Contains(name, ".cdc") { + filename := deURI(cleanWindowsPath(name)) + abs, err := filepath.Abs(filename) + if err != nil { + // If absolute path resolution fails, return original location + return location + } + // Resolve symlinks for consistent canonical file identity (e.g. /var -> /private/var on macOS) + if real, err := filepath.EvalSymlinks(abs); err == nil { + abs = real + } + return common.StringLocation(abs) + } + // String Identifier: resolve via loaded state only + if projectID != "" { + if st, err := p.cfg.ResolveStateForProject(projectID); err == nil && st != nil { + if c, err := st.getState().Contracts().ByName(name); err == nil && c.Location != "" { + abs := filepath.Join(filepath.Dir(st.getConfigPath()), c.Location) + if absPath, err := filepath.Abs(abs); err == nil { + abs = absPath + } + if real, err := filepath.EvalSymlinks(abs); err == nil { + abs = real + } + return common.StringLocation(abs) + } + } + } + return location + case common.IdentifierLocation: + // Convert identifier to file-backed path using loaded state only + if projectID != "" { + name := string(loc) + if st, err := p.cfg.ResolveStateForProject(projectID); err == nil && st != nil { + if c, err := st.getState().Contracts().ByName(name); err == nil && c.Location != "" { + abs := filepath.Join(filepath.Dir(st.getConfigPath()), c.Location) + if absPath, err := filepath.Abs(abs); err == nil { + abs = absPath + } + if real, err := filepath.EvalSymlinks(abs); err == nil { + abs = real + } + return common.StringLocation(abs) + } + } + } + return location + default: + return location } - return stableProjectID(p.cfg.loader, cfgPath) } func (i *FlowIntegration) initialize(initializationOptions any) error { @@ -436,14 +493,14 @@ func (i *FlowIntegration) codeLenses( return actions, nil } -// stableProjectID composes a stable project identifier using an absolute config path and its modtime. -// The format is: @. If stat fails, returns the absolute path without suffix. +// stableProjectID composes a stable project identifier using the canonical absolute config path. +// No modtime suffix is used to ensure identity is consistent across a session. func stableProjectID(loader flowkit.ReaderWriter, cfgPath string) string { if abs, err := filepath.Abs(cfgPath); err == nil { cfgPath = abs } - if fi, err := loader.Stat(cfgPath); err == nil { - return fmt.Sprintf("%s@%d", cfgPath, fi.ModTime().UnixNano()) + if real, err := filepath.EvalSymlinks(cfgPath); err == nil { + cfgPath = real } return cfgPath } diff --git a/languageserver/integration/resolvers.go b/languageserver/integration/resolvers.go index 123f56f6..adbb5f3e 100644 --- a/languageserver/integration/resolvers.go +++ b/languageserver/integration/resolvers.go @@ -67,15 +67,19 @@ func (r *resolvers) resolveStringIdentifierImport(projectID string, name string) if r.cfgManager == nil || projectID == "" { return "", fmt.Errorf("no project context available for identifier import") } - if st, _ := r.cfgManager.ResolveStateForProject(projectID); st != nil { - if code, err := st.GetCodeByName(name); err == nil { - return code, nil - } + // State-backed resolution only to ensure canonicalization and imports share identity + st, stateErr := r.cfgManager.ResolveStateForProject(projectID) + if stateErr != nil { + return "", fmt.Errorf("failed to load project state for %q: %w", projectID, stateErr) + } + if st == nil { + return "", fmt.Errorf("project state is nil for %q", projectID) } - if mapped, _ := r.cfgManager.GetContractSourceForProject(projectID, name); mapped != "" { - return mapped, nil + code, err := st.GetCodeByName(name) + if err != nil { + return "", fmt.Errorf("failed to get code for %q: %w", name, err) } - return "", fmt.Errorf("failed to resolve project state") + return code, nil } // resolveFileImport resolves a file import path relative to the project root if necessary. @@ -120,9 +124,7 @@ func (r *resolvers) addressImport(projectID string, location common.AddressLocat if err != nil || cl == nil { return "", errors.New("client is not initialized") } - if cl == nil { - return "", errors.New("client is not initialized") - } + // cl already checked; no need to re-check nil account, err := cl.GetAccount(flow.HexToAddress(location.Address.String())) if err != nil { @@ -137,6 +139,16 @@ func (r *resolvers) identifierImportProject(projectID string, location common.Id if location == stdlib.CryptoContractLocation { return string(coreContracts.Crypto()), nil } + // Resolve via state + if r.cfgManager != nil && projectID != "" { + if st, err := r.cfgManager.ResolveStateForProject(projectID); err == nil && st != nil { + if code, err := st.GetCodeByName(string(location)); err == nil { + return code, nil + } + } + return "", fmt.Errorf("failed to resolve identifier location %q from project state", location) + } + return "", fmt.Errorf("unknown identifier location: %s", location) } diff --git a/languageserver/languageserver.go b/languageserver/languageserver.go index 5683f6e4..84dd1a01 100644 --- a/languageserver/languageserver.go +++ b/languageserver/languageserver.go @@ -48,7 +48,13 @@ func RunWithStdio(enableFlowClient bool) { panic(err) } - _, err = integration.NewFlowIntegration(languageServer, enableFlowClient) + _, options, err := integration.NewFlowIntegration(languageServer, enableFlowClient) + if err != nil { + panic(err) + } + + // Apply server options from integration + err = languageServer.SetOptions(options...) if err != nil { panic(err) } diff --git a/languageserver/protocol/methods.go b/languageserver/protocol/methods.go index 81a00cab..aae49b87 100644 --- a/languageserver/protocol/methods.go +++ b/languageserver/protocol/methods.go @@ -49,6 +49,15 @@ func (s *Server) handleDidChangeTextDocument(req *json.RawMessage) (any, error) return nil, err } +func (s *Server) handleDidCloseTextDocument(req *json.RawMessage) (any, error) { + var params DidCloseTextDocumentParams + if err := json.Unmarshal(*req, ¶ms); err != nil { + return nil, err + } + err := s.Handler.DidCloseTextDocument(s.conn, ¶ms) + return nil, err +} + func (s *Server) handleHover(req *json.RawMessage) (any, error) { var params TextDocumentPositionParams if err := json.Unmarshal(*req, ¶ms); err != nil { diff --git a/languageserver/protocol/server.go b/languageserver/protocol/server.go index ac90ca06..78b4667b 100644 --- a/languageserver/protocol/server.go +++ b/languageserver/protocol/server.go @@ -91,6 +91,7 @@ type Handler interface { Initialize(conn Conn, params *InitializeParams) (*InitializeResult, error) DidOpenTextDocument(conn Conn, params *DidOpenTextDocumentParams) error DidChangeTextDocument(conn Conn, params *DidChangeTextDocumentParams) error + DidCloseTextDocument(conn Conn, params *DidCloseTextDocumentParams) error Hover(conn Conn, params *TextDocumentPositionParams) (*Hover, error) Definition(conn Conn, params *TextDocumentPositionParams) (*Location, error) SignatureHelp(conn Conn, params *TextDocumentPositionParams) (*SignatureHelp, error) @@ -131,6 +132,9 @@ func NewServer(handler Handler) *Server { jsonrpc2Server.Methods["textDocument/didChange"] = server.handleDidChangeTextDocument + jsonrpc2Server.Methods["textDocument/didClose"] = + server.handleDidCloseTextDocument + jsonrpc2Server.Methods["textDocument/hover"] = server.handleHover diff --git a/languageserver/run.sh b/languageserver/run.sh index 564c5335..93b1e81e 100755 --- a/languageserver/run.sh +++ b/languageserver/run.sh @@ -4,7 +4,8 @@ SCRIPTPATH=$(dirname "$0") if [ "$1" = "cadence" ] && [ "$2" = "language-server" ] ; then (cd "$SCRIPTPATH" && \ - go run -gcflags="all=-N -l" ./cmd/languageserver --enable-flow-client=false); + go build -gcflags="all=-N -l" ./cmd/languageserver && \ + dlv --log-dest 2 --continue --listen=:2369 --headless=true --api-version=2 --accept-multiclient exec ./languageserver -- "$@"); else flow "$@" -fi +fi \ No newline at end of file diff --git a/languageserver/server.test b/languageserver/server.test new file mode 100755 index 00000000..f6e64e39 Binary files /dev/null and b/languageserver/server.test differ diff --git a/languageserver/server/checker_store.go b/languageserver/server/checker_store.go new file mode 100644 index 00000000..e6d46e81 --- /dev/null +++ b/languageserver/server/checker_store.go @@ -0,0 +1,277 @@ +package server + +import ( + "sync" + + "github.com/onflow/cadence/common" + "github.com/onflow/cadence/sema" +) + +// CheckerKey identifies a cached checker instance within a project scope. +type CheckerKey struct { + ProjectID string + Location common.Location +} + +// CheckerStore caches checkers, tracks a parent→child graph, and only supports +// boolean root pins. Import liveness is implicit via number of incoming edges. +type CheckerStore struct { + mu sync.RWMutex + + checkers map[CheckerKey]*sema.Checker + + // Dependency graph: + // children[parent] -> set of children + // parents[child] -> set of parents + children map[CheckerKey]map[CheckerKey]struct{} + parents map[CheckerKey]map[CheckerKey]struct{} + + // Root pins: true if this key is explicitly kept as a root. + rootPinned map[CheckerKey]bool +} + +func NewCheckerStore() *CheckerStore { + return &CheckerStore{ + checkers: make(map[CheckerKey]*sema.Checker), + children: make(map[CheckerKey]map[CheckerKey]struct{}), + parents: make(map[CheckerKey]map[CheckerKey]struct{}), + rootPinned: make(map[CheckerKey]bool), + } +} + +// Get retrieves a cached checker by key. +func (s *CheckerStore) Get(key CheckerKey) (*sema.Checker, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + chk, ok := s.checkers[key] + return chk, ok +} + +// Put stores/updates a checker under its key. +func (s *CheckerStore) Put(key CheckerKey, chk *sema.Checker) { + s.mu.Lock() + defer s.mu.Unlock() + s.checkers[key] = chk +} + +// Delete removes a checker (manual override). +// Does not touch edges; prefer RemoveParent* / Invalidate for usual flows. +func (s *CheckerStore) Delete(key CheckerKey) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.checkers, key) + delete(s.rootPinned, key) + // Best-effort: remove outgoing edges and clean up children's parent sets. + if kids, ok := s.children[key]; ok { + for child := range kids { + if ps, ok := s.parents[child]; ok { + delete(ps, key) + if len(ps) == 0 { + delete(s.parents, child) + } + } + } + delete(s.children, key) + } + // If this makes any children cold, evict them too. + for child := range s.parents[key] { + s.evictIfColdUnsafe(child) + } + delete(s.parents, key) +} + +// RemoveCheckerOnly removes a checker from the cache and unpins it, but preserves +// all dependency edges. This is useful when a file is closed but we want to maintain +// the dependency graph for transitive change propagation. +func (s *CheckerStore) RemoveCheckerOnly(key CheckerKey) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.checkers, key) + delete(s.rootPinned, key) +} + +// Invalidate removes the checker for (projectID, location), clears its root pin, +// and prunes all outgoing edges. Cold children (and possibly the root) are evicted. +func (s *CheckerStore) Invalidate(projectID string, location common.Location) { + key := CheckerKey{ProjectID: projectID, Location: location} + + s.mu.Lock() + defer s.mu.Unlock() + + delete(s.checkers, key) + delete(s.rootPinned, key) + + // Remove edges parent(key) -> children and cascade-candidate children list. + children := s.removeParentAndCollectChildrenUnsafe(key) + + // Try to evict children that became cold (no parents, not root-pinned). + for _, child := range children { + s.evictIfColdUnsafe(child) + } + // The invalidated root itself may be cold if it had no parents. + s.evictIfColdUnsafe(key) +} + +// PinRoot marks a checker as an actively opened root. +func (s *CheckerStore) PinRoot(key CheckerKey) { + s.mu.Lock() + defer s.mu.Unlock() + s.rootPinned[key] = true +} + +// UnpinRoot marks a checker as no longer opened as a root. +// If it now has no parents (i.e., nobody imports it), evict it. +func (s *CheckerStore) UnpinRoot(key CheckerKey) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.rootPinned, key) + s.evictIfColdUnsafe(key) +} + +// AddEdge adds a dependency edge parent -> child. +func (s *CheckerStore) AddEdge(parent, child CheckerKey) { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.children[parent]; !ok { + s.children[parent] = make(map[CheckerKey]struct{}) + } + if _, ok := s.parents[child]; !ok { + s.parents[child] = make(map[CheckerKey]struct{}) + } + // idempotent + if _, exists := s.children[parent][child]; exists { + return + } + s.children[parent][child] = struct{}{} + s.parents[child][parent] = struct{}{} +} + +// RemoveParent removes all outgoing edges from a parent and evicts any children +// that become cold as a result. The parent itself may also become cold. +func (s *CheckerStore) RemoveParent(parent CheckerKey) { + s.mu.Lock() + defer s.mu.Unlock() + children := s.removeParentAndCollectChildrenUnsafe(parent) + for _, child := range children { + s.evictIfColdUnsafe(child) + } + s.evictIfColdUnsafe(parent) +} + +// ClearChildren removes all outgoing edges from parent without evicting nodes. +// Intended for change events where dependencies will be rebuilt immediately. +func (s *CheckerStore) ClearChildren(parent CheckerKey) { + s.mu.Lock() + defer s.mu.Unlock() + s.removeParentUnsafe(parent) +} + +// RemoveParentAndCollectChildren removes all outgoing edges from parent and returns previous children. +func (s *CheckerStore) RemoveParentAndCollectChildren(parent CheckerKey) []CheckerKey { + s.mu.Lock() + defer s.mu.Unlock() + return s.removeParentAndCollectChildrenUnsafe(parent) +} + +// AffectedParents returns all transitive parents that depend on key. +func (s *CheckerStore) AffectedParents(key CheckerKey) []CheckerKey { + s.mu.RLock() + defer s.mu.RUnlock() + + visited := make(map[CheckerKey]struct{}) + queue := make([]CheckerKey, 0) + + for p := range s.parents[key] { + queue = append(queue, p) + } + + out := make([]CheckerKey, 0) + for len(queue) > 0 { + cur := queue[0] + queue = queue[1:] + if _, seen := visited[cur]; seen { + continue + } + visited[cur] = struct{}{} + out = append(out, cur) + for pp := range s.parents[cur] { + if _, seen := visited[pp]; !seen { + queue = append(queue, pp) + } + } + } + return out +} + +// ---- internal (caller must hold lock) ---- + +func (s *CheckerStore) removeParentUnsafe(parent CheckerKey) { + children, ok := s.children[parent] + if !ok { + return + } + for child := range children { + if ps, ok := s.parents[child]; ok { + delete(ps, parent) + if len(ps) == 0 { + delete(s.parents, child) + } + } + } + delete(s.children, parent) +} + +func (s *CheckerStore) removeParentAndCollectChildrenUnsafe(parent CheckerKey) []CheckerKey { + children, ok := s.children[parent] + if !ok { + return nil + } + out := make([]CheckerKey, 0, len(children)) + for child := range children { + out = append(out, child) + if ps, ok := s.parents[child]; ok { + delete(ps, parent) + if len(ps) == 0 { + delete(s.parents, child) + } + } + } + delete(s.children, parent) + return out +} + +// isCold: true if not root-pinned and has no incoming edges (imports). +func (s *CheckerStore) isColdUnsafe(key CheckerKey) bool { + if s.rootPinned[key] { + return false + } + if ps, ok := s.parents[key]; ok && len(ps) > 0 { + return false + } + return true +} + +// evictIfColdUnsafe deletes key if cold and cascades to any children that +// become cold once this node is removed. +func (s *CheckerStore) evictIfColdUnsafe(key CheckerKey) { + if !s.isColdUnsafe(key) { + return + } + // Remove and detach from children + kids := s.children[key] + delete(s.checkers, key) + delete(s.children, key) + delete(s.parents, key) + delete(s.rootPinned, key) + + // Removing this node as a parent might make children cold now. + for child := range kids { + if ps, ok := s.parents[child]; ok { + delete(ps, key) + if len(ps) == 0 { + delete(s.parents, child) + } + } + s.evictIfColdUnsafe(child) + } +} diff --git a/languageserver/server/file_watcher.go b/languageserver/server/file_watcher.go new file mode 100644 index 00000000..5752e7ab --- /dev/null +++ b/languageserver/server/file_watcher.go @@ -0,0 +1,130 @@ +/* + * Cadence languageserver - The Cadence language server + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package server + +import ( + "sync" + "time" + + "github.com/onflow/cadence/common" + "github.com/onflow/cadence-tools/languageserver/protocol" +) + +// FileWatcher handles watching .cdc files and invalidating caches when they change +type FileWatcher struct { + server *Server + debouncer *debouncer +} + +// debouncer handles debouncing of repeated events by key +type debouncer struct { + mu sync.Mutex + timers map[string]*time.Timer + delay time.Duration +} + +func newDebouncer(delay time.Duration) *debouncer { + return &debouncer{ + timers: make(map[string]*time.Timer), + delay: delay, + } +} + +func (d *debouncer) schedule(key string, fn func()) { + d.mu.Lock() + defer d.mu.Unlock() + + if t, ok := d.timers[key]; ok && t != nil { + t.Stop() + } + + d.timers[key] = time.AfterFunc(d.delay, func() { + fn() + d.mu.Lock() + delete(d.timers, key) + d.mu.Unlock() + }) +} + +// NewFileWatcher creates a new file watcher for the server +func NewFileWatcher(s *Server) *FileWatcher { + return &FileWatcher{ + server: s, + debouncer: newDebouncer(500 * time.Millisecond), + } +} + +// HandleFileChanged invalidates cached checkers and re-checks dependent files when a .cdc file changes +func (fw *FileWatcher) HandleFileChanged(absPath string) { + fw.debouncer.schedule(absPath, func() { + fw.handleFileChangedSync(absPath) + }) +} + +func (fw *FileWatcher) handleFileChangedSync(absPath string) { + uri := protocol.DocumentURI("file://" + absPath) + proj := fw.server.projectResolver.ProjectIDForURI(uri) + canonical := fw.server.projectResolver.CanonicalLocation(proj, common.StringLocation(absPath)) + key := CheckerKey{ProjectID: proj, Location: canonical} + + // Clear outgoing edges since imports will be rebuilt, but preserve incoming edges + fw.server.store.ClearChildren(key) + fw.server.store.RemoveCheckerOnly(key) + + // Invalidate all transitive parent checkers (they reference types from this file) + allParents := fw.server.store.AffectedParents(key) + for _, parent := range allParents { + fw.server.store.RemoveCheckerOnly(parent) + } + + // Re-check any open documents that depend on this file + affected := fw.server.collectAffectedOpenRootURIs(key, "") + conn := fw.server.conn + for _, depURI := range affected { + if doc, ok := fw.server.documents[depURI]; ok { + fw.server.checkAndPublishDiagnostics(conn, depURI, doc.Text, doc.Version) + } + } +} + +// HandleProjectFilesChanged re-checks all open files in a project when .cdc files are created/deleted +func (fw *FileWatcher) HandleProjectFilesChanged(projectID string) { + fw.debouncer.schedule("project:"+projectID, func() { + fw.handleProjectFilesChangedSync(projectID) + }) +} + +func (fw *FileWatcher) handleProjectFilesChangedSync(projectID string) { + defer func() { + if r := recover(); r != nil { + // Log panic but don't crash the server + // This can happen if state reload fails and checking encounters issues + } + }() + + // Re-check all open documents in this project + conn := fw.server.conn + for uri, doc := range fw.server.documents { + proj := fw.server.projectResolver.ProjectIDForURI(uri) + if proj == projectID { + fw.server.checkAndPublishDiagnostics(conn, uri, doc.Text, doc.Version) + } + } +} + diff --git a/languageserver/server/linting_test.go b/languageserver/server/linting_test.go index d1586955..00e52a10 100644 --- a/languageserver/server/linting_test.go +++ b/languageserver/server/linting_test.go @@ -30,7 +30,7 @@ import ( func checkProgram(t *testing.T, text string) []protocol.Diagnostic { server, err := NewServer() assert.NoError(t, err) - diagnostics, err := server.getDiagnostics("", text, 0, func(_ *protocol.LogMessageParams) {}) + diagnostics, err := server.getDiagnostics("", text, 0, func(_ *protocol.LogMessageParams) {}, false) assert.NoError(t, err) return diagnostics } diff --git a/languageserver/server/server.go b/languageserver/server/server.go index 85a21a46..b76ab2a0 100644 --- a/languageserver/server/server.go +++ b/languageserver/server/server.go @@ -165,7 +165,7 @@ type CodeActionResolver func() []*protocol.CodeAction type Server struct { protocolServer *protocol.Server - checkerCache CheckerCache + conn protocol.Conn documents map[protocol.DocumentURI]Document memberResolvers map[protocol.DocumentURI]map[string]sema.MemberResolver ranges map[protocol.DocumentURI]map[string]sema.Range @@ -205,72 +205,41 @@ type Server struct { // onDidOpen, if set, is invoked after a document is opened onDidOpen func(conn protocol.Conn, uri protocol.DocumentURI, text string) - // keyResolver builds cache keys for root/imported checkers - keyResolver CheckerKeyResolver - // projectIdentity provides a project-scoped identity for a given document URI - projectIdentity ProjectIdentityProvider -} + // projectResolver provides project-scoped location canonicalization and identity + projectResolver ProjectResolver -// checkerKey scopes a cached checker by its originating document URI and the imported location. -// This prevents collisions for name-based imports (e.g., common.StringLocation) across projects. -// CheckerKey identifies a cached checker instance. -type CheckerKey struct { - ProjectID string - Location common.Location -} + // store holds checkers and their dependency relationships + store *CheckerStore + // fileWatcher handles watching .cdc files and invalidating caches + fileWatcher *FileWatcher -// ProjectIdentityProvider maps a document URI to a stable project identity (e.g., abs flow.json path). -type ProjectIdentityProvider interface { - ProjectIDForURI(uri protocol.DocumentURI) string + // single-flight guard for sub-checker creation keyed by (projectID, canonical child) + subCheckerFlightMu sync.Mutex + subCheckerFlight map[CheckerKey]chan struct{} } // CheckerKeyResolver composes keys for root and imported checkers. -type CheckerKeyResolver interface { - KeyForRoot(projectID string, rootURI protocol.DocumentURI) CheckerKey - KeyForImport(parent CheckerKey, imported common.Location) CheckerKey -} - -// CheckerCache is a storage for checkers keyed by CheckerKey. -type CheckerCache interface { - Get(key CheckerKey) (*sema.Checker, bool) - Put(key CheckerKey, checker *sema.Checker) - Delete(key CheckerKey) -} +// Checker keys are derived directly via projectResolver.CanonicalLocation // Default implementations -type defaultProjectIdentity struct{} - -func (defaultProjectIdentity) ProjectIDForURI(uri protocol.DocumentURI) string { - return "" -} -type defaultKeyResolver struct{} - -func (defaultKeyResolver) KeyForRoot(projectID string, rootURI protocol.DocumentURI) CheckerKey { - return CheckerKey{ProjectID: projectID, Location: uriToLocation(rootURI)} -} - -func (defaultKeyResolver) KeyForImport(parent CheckerKey, imported common.Location) CheckerKey { - return CheckerKey{ProjectID: parent.ProjectID, Location: imported} +// ProjectResolver provides project-scoped identity canonicalization for locations +type ProjectResolver interface { + ProjectIDForURI(uri protocol.DocumentURI) string + CanonicalLocation(projectID string, location common.Location) common.Location } -type mapCheckerCache struct{ m map[CheckerKey]*sema.Checker } +type defaultProjectResolver struct{} -func newMapCheckerCache() *mapCheckerCache { - return &mapCheckerCache{m: make(map[CheckerKey]*sema.Checker)} -} -func (c *mapCheckerCache) Get(key CheckerKey) (*sema.Checker, bool) { - v, ok := c.m[key] - return v, ok +func (defaultProjectResolver) ProjectIDForURI(_ protocol.DocumentURI) string { + return "" } -func (c *mapCheckerCache) Put(key CheckerKey, checker *sema.Checker) { - c.m[key] = checker +func (defaultProjectResolver) CanonicalLocation(_ string, location common.Location) common.Location { + return location } -func (c *mapCheckerCache) Delete(key CheckerKey) { - delete(c.m, key) -} +// (legacy default key resolver removed) type Option func(*Server) error @@ -282,35 +251,18 @@ func WithDidOpenHook(hook func(conn protocol.Conn, uri protocol.DocumentURI, tex } } -// WithProjectIdentityProvider sets the project identity provider used for cache scoping -func WithProjectIdentityProvider(p ProjectIdentityProvider) Option { - return func(s *Server) error { - if p != nil { - s.projectIdentity = p - } - return nil - } -} - -// WithCheckerKeyResolver sets the resolver that builds checker cache keys -func WithCheckerKeyResolver(r CheckerKeyResolver) Option { +// WithProjectResolver sets the project resolver used for location canonicalization and identity +func WithProjectResolver(r ProjectResolver) Option { return func(s *Server) error { if r != nil { - s.keyResolver = r + s.projectResolver = r } return nil } } -// WithCheckerCache sets a custom checker cache implementation -func WithCheckerCache(c CheckerCache) Option { - return func(s *Server) error { - if c != nil { - s.checkerCache = c - } - return nil - } -} +// WithCheckerKeyResolver sets the resolver that builds checker cache keys +// WithCheckerKeyResolver removed; keys are derived via projectResolver type Command struct { Name string @@ -427,18 +379,22 @@ const ParseEntryPointArgumentsCommand = "cadence.server.parseEntryPointArguments func NewServer() (*Server, error) { server := &Server{ - checkerCache: newMapCheckerCache(), documents: make(map[protocol.DocumentURI]Document), memberResolvers: make(map[protocol.DocumentURI]map[string]sema.MemberResolver), ranges: make(map[protocol.DocumentURI]map[string]sema.Range), codeActionsResolvers: make(map[protocol.DocumentURI]map[uuid.UUID]CodeActionResolver), commands: make(map[string]CommandHandler), accessCheckMode: sema.AccessCheckModeStrict, - keyResolver: defaultKeyResolver{}, - projectIdentity: defaultProjectIdentity{}, + projectResolver: defaultProjectResolver{}, } server.protocolServer = protocol.NewServer(server) + // initialize checker store (cache + graph) + server.store = NewCheckerStore() + server.subCheckerFlight = make(map[CheckerKey]chan struct{}) + // initialize file watcher for cache invalidation + server.fileWatcher = NewFileWatcher(server) + // init crash reporting defer sentry.Flush(2 * time.Second) defer sentry.Recover() @@ -504,9 +460,14 @@ func (s *Server) Stop() error { } func (s *Server) checkerForDocument(uri protocol.DocumentURI) *sema.Checker { - projectID := s.projectIdentity.ProjectIDForURI(uri) - key := s.keyResolver.KeyForRoot(projectID, uri) - if chk, ok := s.checkerCache.Get(key); ok { + if s.projectResolver == nil { + return nil + } + projectID := s.projectResolver.ProjectIDForURI(uri) + // Derive canonical key directly + canonical := s.projectResolver.CanonicalLocation(projectID, uriToLocation(uri)) + key := CheckerKey{ProjectID: projectID, Location: canonical} + if chk, ok := s.store.Get(key); ok { return chk } return nil @@ -519,6 +480,8 @@ func (s *Server) Initialize( *protocol.InitializeResult, error, ) { + // retain connection for background-triggered rechecks + s.conn = conn result := &protocol.InitializeResult{ Capabilities: protocol.ServerCapabilities{ TextDocumentSync: protocol.Full, @@ -737,6 +700,15 @@ func (s *Server) DidOpenTextDocument(conn protocol.Conn, params *protocol.DidOpe if s.onDidOpen != nil { s.onDidOpen(conn, uri, text) } + // Pin root canonical key (opened document) + var projID string + if s.projectResolver != nil { + projID = s.projectResolver.ProjectIDForURI(uri) + } + canonical := s.projectResolver.CanonicalLocation(projID, uriToLocation(uri)) + rootCanonicalKey := CheckerKey{ProjectID: projID, Location: canonical} + s.store.PinRoot(rootCanonicalKey) + s.checkAndPublishDiagnostics(conn, uri, text, version) return nil @@ -757,13 +729,35 @@ func (s *Server) DidChangeTextDocument( Version: version, } - // todo implement smarter cache invalidation with dependency resolution using https://github.com/onflow/cadence/pull/1634 - // we should build dependency tree upfront and then based on the changes only reset checkers contained in that tree - // Clear only the root checker for this document; sub-checkers are scoped by project - rootKey := s.keyResolver.KeyForRoot(s.projectIdentity.ProjectIDForURI(uri), uri) - s.checkerCache.Delete(rootKey) + var projID string + if s.projectResolver != nil { + projID = s.projectResolver.ProjectIDForURI(uri) + } + canonical := s.projectResolver.CanonicalLocation(projID, uriToLocation(uri)) + canonicalKey := CheckerKey{ProjectID: projID, Location: canonical} - s.checkAndPublishDiagnostics(conn, uri, text, version) + // Remove the cached checker but preserve incoming edges so change propagation still works + // NOTE: We do NOT clear children here - getDiagnostics will do it when clearChildren=true + s.store.RemoveCheckerOnly(canonicalKey) + + // Invalidate cached checkers of ALL transitive parents (they transitively reference types from this file) + // This ensures parents rebuild with fresh imports when re-checked + allParents := s.store.AffectedParents(canonicalKey) + for _, parent := range allParents { + s.store.RemoveCheckerOnly(parent) + } + + // Re-check the changed document first (this will Put a new checker in the cache) + // Pass clearChildren=true since this is a direct edit + s.checkAndPublishDiagnosticsWithClearChildren(conn, uri, text, version, true) + + // Then re-check any open documents that depend on this file (directly or transitively) + affectedURIs := s.collectAffectedOpenRootURIs(canonicalKey, uri) + for _, depURI := range affectedURIs { + if doc, ok := s.documents[depURI]; ok { + s.checkAndPublishDiagnostics(conn, depURI, doc.Text, doc.Version) + } + } return nil } @@ -786,8 +780,19 @@ func (s *Server) checkAndPublishDiagnostics( text string, version int32, ) { + // Default: don't clear children (for dependent re-checks using cached imports) + s.checkAndPublishDiagnosticsWithClearChildren(conn, uri, text, version, false) +} - diagnostics, _ := s.getDiagnostics(uri, text, version, conn.LogMessage) +func (s *Server) checkAndPublishDiagnosticsWithClearChildren( + conn protocol.Conn, + uri protocol.DocumentURI, + text string, + version int32, + clearChildren bool, +) { + + diagnostics, _ := s.getDiagnostics(uri, text, version, conn.LogMessage, clearChildren) // NOTE: always publish diagnostics and inform the client the checking completed @@ -2036,6 +2041,7 @@ func (s *Server) getDiagnostics( text string, version int32, log func(*protocol.LogMessageParams), + clearChildren bool, // Only clear children if this is a direct edit, not a dependent re-check ) ( diagnostics []protocol.Diagnostic, diagnosticsErr error, @@ -2065,13 +2071,30 @@ func (s *Server) getDiagnostics( location := uriToLocation(uri) if program == nil { - rootKey := s.keyResolver.KeyForRoot(s.projectIdentity.ProjectIDForURI(uri), uri) - s.checkerCache.Delete(rootKey) + // On parse failure, clear existing checker and edges for this root alias + var projID string + if s.projectResolver != nil { + projID = s.projectResolver.ProjectIDForURI(uri) + } + // On parse failure, remove cached checker but preserve incoming edges for change propagation + canonical := s.projectResolver.CanonicalLocation(projID, uriToLocation(uri)) + key := CheckerKey{ProjectID: projID, Location: canonical} + s.store.RemoveCheckerOnly(key) return } // Build checker with a config that captures the root project ID for all nested imports - projID := s.projectIdentity.ProjectIDForURI(uri) + var projID string + if s.projectResolver != nil { + projID = s.projectResolver.ProjectIDForURI(uri) + } + // Reset existing dependency edges for this root before re-checking; new edges will be recorded during import resolution + // Only clear children if this is a direct edit (not a dependent re-check using cached imports) + if clearChildren { + rootCanonical := s.projectResolver.CanonicalLocation(projID, uriToLocation(uri)) + parentKey := CheckerKey{ProjectID: projID, Location: rootCanonical} + s.store.ClearChildren(parentKey) + } cfgCopy := s.decideCheckerConfig(projID, program) var checker *sema.Checker checker, diagnosticsErr = sema.NewChecker( @@ -2095,9 +2118,10 @@ func (s *Server) getDiagnostics( Message: fmt.Sprintf("checking %s took %s", string(uri), elapsed), }) - // Cache the root checker by project-aware key - rootKey := s.keyResolver.KeyForRoot(projID, uri) - s.checkerCache.Put(rootKey, checker) + // Cache the root checker using its canonical key + canonical := s.projectResolver.CanonicalLocation(projID, uriToLocation(uri)) + rootCanonicalKey := CheckerKey{ProjectID: projID, Location: canonical} + s.store.Put(rootCanonicalKey, checker) if checkError != nil { if parentErr, ok := checkError.(errors.ParentError); ok { @@ -2225,6 +2249,15 @@ func parse(code, location string, log func(*protocol.LogMessageParams)) (*ast.Pr // enabling per-document configuration (multi-config) resolution. // projectID is the root project identity for the entire checking session. func (s *Server) resolveImport(projectID string, checker *sema.Checker, location common.Location) (program *ast.Program, err error) { + // Prefer in-memory overlays for any import that canonicalizes to a file-backed path + canonical := s.projectResolver.CanonicalLocation(projectID, location) + if p := locationToPath(canonical); p != "" { + if uri, ok := s.findOpenURIForKey(CheckerKey{ProjectID: projectID, Location: canonical}); ok { + if doc, has := s.documents[uri]; has { + return parser.ParseProgram(nil, []byte(doc.Text), parser.Config{}) + } + } + } // NOTE: important, *DON'T* return an error when a location type // is not supported: the import location can simply not be resolved, // no error occurred while resolving it. @@ -2264,6 +2297,21 @@ func (s *Server) GetDocument(uri protocol.DocumentURI) (doc Document, ok bool) { return } +func (s *Server) GetAllDocuments() map[protocol.DocumentURI]Document { + // Return a copy to avoid external mutation + docs := make(map[protocol.DocumentURI]Document, len(s.documents)) + for uri, doc := range s.documents { + docs[uri] = doc + } + return docs +} + +// FileWatcher returns the server's file watcher for external file change notifications +func (s *Server) FileWatcher() *FileWatcher { return s.fileWatcher } + +// ProjectResolver returns the server's project resolver +func (s *Server) ProjectResolver() ProjectResolver { return s.projectResolver } + func (s *Server) defaultCommands() []Command { return []Command{ { @@ -3143,29 +3191,192 @@ func (s *Server) handleImport(projectID string, checker *sema.Checker, importedL } } } - cacheKey := CheckerKey{ProjectID: projectID, Location: importedLocation} - if c, ok := s.checkerCache.Get(cacheKey); ok && c != nil { - return sema.ElaborationImport{Elaboration: c.Elaboration}, nil + parentKey := CheckerKey{ProjectID: projectID, Location: s.projectResolver.CanonicalLocation(projectID, checker.Location)} + canonicalChild := s.projectResolver.CanonicalLocation(projectID, importedLocation) + cacheKey := CheckerKey{ProjectID: projectID, Location: canonicalChild} + + // Always try cache first to ensure a single checker instance per (project, location) + if cachedChecker, ok := s.store.Get(cacheKey); ok { + s.store.AddEdge(parentKey, cacheKey) + // Check if the cached checker has errors and propagate them + if checkerError := cachedChecker.CheckerError(); checkerError != nil { + return nil, checkerError + } + return sema.ElaborationImport{Elaboration: cachedChecker.Elaboration}, nil + } + + // Otherwise, build a new sub-checker (single-flight by canonical child key). Prefer in-memory text if the imported file is open. + // Single-flight guard: ensure only one builder per canonical child runs at a time + s.subCheckerFlightMu.Lock() + if ch, ok := s.subCheckerFlight[cacheKey]; ok { + s.subCheckerFlightMu.Unlock() + <-ch + if cachedChecker, ok := s.store.Get(cacheKey); ok { + s.store.AddEdge(parentKey, cacheKey) + if checkerError := cachedChecker.CheckerError(); checkerError != nil { + return nil, checkerError + } + return sema.ElaborationImport{Elaboration: cachedChecker.Elaboration}, nil + } + // If checker not found after waiting, another goroutine failed to build it + // Return an error rather than trying to build it again (which would cause a mutex error) + return nil, &sema.CheckerError{ + Errors: []error{fmt.Errorf("failed to import %s: checker not available after build", importedLocation)}, + } + } + ch := make(chan struct{}) + s.subCheckerFlight[cacheKey] = ch + s.subCheckerFlightMu.Unlock() + + defer func() { + s.subCheckerFlightMu.Lock() + delete(s.subCheckerFlight, cacheKey) + close(ch) + s.subCheckerFlightMu.Unlock() + }() + var importedProgram *ast.Program + var err error + if p := locationToPath(importedLocation); p != "" { // try non-canonical first to match open-doc URIs + uri := protocol.DocumentURI(filePrefix + p) + if doc, ok := s.documents[uri]; ok { + importedProgram, err = parser.ParseProgram(nil, []byte(doc.Text), parser.Config{}) + } + } + if importedProgram == nil && err == nil { + if p := locationToPath(canonicalChild); p != "" { + // Try to find an open document matching the canonical path, resolving symlinks + openURI, found := s.findOpenURIForKey(CheckerKey{ProjectID: projectID, Location: canonicalChild}) + if found { + if doc, ok := s.documents[openURI]; ok { + importedProgram, err = parser.ParseProgram(nil, []byte(doc.Text), parser.Config{}) + } + } else { + // Fallback to direct URI + uri := protocol.DocumentURI(filePrefix + p) + if doc, ok := s.documents[uri]; ok { + importedProgram, err = parser.ParseProgram(nil, []byte(doc.Text), parser.Config{}) + } + } + } + } + if importedProgram == nil && err == nil { + importedProgram, err = s.resolveImport(projectID, checker, importedLocation) } - importedProgram, err := s.resolveImport(projectID, checker, importedLocation) if err != nil { return nil, err } if importedProgram == nil { return nil, &sema.CheckerError{Errors: []error{fmt.Errorf("cannot import %s", importedLocation)}} } - importedChecker, err := checker.SubChecker(importedProgram, importedLocation) + // Use canonical child location to unify type identity across different import forms + importedChecker, err := checker.SubChecker(importedProgram, canonicalChild) if err != nil { return nil, err } + + // Store and track dependency edges BEFORE checking, so edges exist even if imported file has errors + s.store.Put(cacheKey, importedChecker) + s.store.AddEdge(parentKey, cacheKey) + if err := importedChecker.Check(); err != nil { return nil, err } - s.checkerCache.Put(cacheKey, importedChecker) return sema.ElaborationImport{Elaboration: importedChecker.Elaboration}, nil } } +// RemoveParentAndCollectChildren removes all outgoing edges from parent and returns previous children +func (s *Server) RemoveParentAndCollectChildren(parent CheckerKey) []CheckerKey { + return s.store.RemoveParentAndCollectChildren(parent) +} + +// collectAffectedOpenRootURIs returns open document URIs that (transitively) depend on the given key, excluding excludeURI +func (s *Server) collectAffectedOpenRootURIs(key CheckerKey, excludeURI protocol.DocumentURI) []protocol.DocumentURI { + // Accept canonical key + parents := s.store.AffectedParents(key) + seenURIs := make(map[protocol.DocumentURI]struct{}) + for _, p := range parents { + if uri, ok := s.findOpenURIForKey(p); ok { + if _, open := s.documents[uri]; open && uri != excludeURI { + seenURIs[uri] = struct{}{} + } + } + } + out := make([]protocol.DocumentURI, 0, len(seenURIs)) + for u := range seenURIs { + out = append(out, u) + } + return out +} + +// findOpenURIForKey tries to find an open document URI that corresponds to the given canonical checker key, +// resolving symlinks to handle /var vs /private/var path differences on macOS. +func (s *Server) findOpenURIForKey(key CheckerKey) (protocol.DocumentURI, bool) { + p := locationToPath(key.Location) + if p == "" { + return "", false + } + // Resolve symlinks for canonical path + canonicalPath := p + if real, err := filepath.EvalSymlinks(p); err == nil { + canonicalPath = real + } + for uri := range s.documents { + upath := strings.TrimPrefix(string(uri), filePrefix) + // Resolve symlinks for open document path + realPath := upath + if r, err := filepath.EvalSymlinks(upath); err == nil { + realPath = r + } + if realPath == canonicalPath { + return uri, true + } + } + // Not currently open + return "", false +} + +// uriForKey converts a checker cache key to a document URI (only for path-based locations) +func (s *Server) uriForKey(key CheckerKey) (protocol.DocumentURI, bool) { + if p := locationToPath(key.Location); p != "" { + return protocol.DocumentURI(filePrefix + p), true + } + return "", false +} + +// DidCloseTextDocument is called whenever a document is closed. +// We clear diagnostics, invalidate the cached checker, and prune dependency edges for the closed root. +func (s *Server) DidCloseTextDocument( + conn protocol.Conn, + params *protocol.DidCloseTextDocumentParams, +) error { + uri := params.TextDocument.URI + delete(s.documents, uri) + var projID string + if s.projectResolver != nil { + projID = s.projectResolver.ProjectIDForURI(uri) + } + canonical := s.projectResolver.CanonicalLocation(projID, uriToLocation(uri)) + key := CheckerKey{ProjectID: projID, Location: canonical} + + // Remove the checker from cache but KEEP dependency edges + // This allows transitive propagation to still work (A->B->C, closing B still lets C changes reach A) + s.store.RemoveCheckerOnly(key) + + // Clear diagnostics for the closed file + if err := conn.PublishDiagnostics(&protocol.PublishDiagnosticsParams{ + URI: uri, + Diagnostics: []protocol.Diagnostic{}, + }); err != nil { + // Log error but don't fail - diagnostics clearing is best-effort + conn.LogMessage(&protocol.LogMessageParams{ + Type: protocol.Error, + Message: fmt.Sprintf("Failed to clear diagnostics: %v", err), + }) + } + return nil +} + func extractIndentation(text string, pos ast.Position) string { lineStartOffset := pos.Offset - pos.Column indentationEndOffset := lineStartOffset diff --git a/languageserver/test/dependency_graph.test.ts b/languageserver/test/dependency_graph.test.ts new file mode 100644 index 00000000..73e3cb9a --- /dev/null +++ b/languageserver/test/dependency_graph.test.ts @@ -0,0 +1,1349 @@ +import { + createProtocolConnection, + InitializeRequest, + ExitNotification, + StreamMessageReader, + StreamMessageWriter, + ProtocolConnection, + DidOpenTextDocumentNotification, + DidChangeTextDocumentNotification, + DidCloseTextDocumentNotification, + PublishDiagnosticsNotification, + PublishDiagnosticsParams, + TextDocumentItem, + VersionedTextDocumentIdentifier, +} from "vscode-languageserver-protocol"; + +import { execSync, spawn } from "child_process"; +import * as path from "path"; +import * as fs from "fs"; +import * as os from "os"; + +beforeAll(() => { + execSync("go build ../cmd/languageserver", { + cwd: __dirname, + }); +}); + +async function withConnection( + f: (connection: ProtocolConnection) => Promise, + rootUri: string = "/" +): Promise { + const child = spawn(path.resolve(__dirname, "./languageserver"), [ + "--enable-flow-client=false", + ]); + + // Log stderr output for debugging + child.stderr.on('data', (data) => { + console.error(`Language server stderr: ${data}`); + }); + + child.on('exit', (code, signal) => { + if (code !== null && code !== 0) { + console.error(`Language server exited with code ${code}`); + } + if (signal !== null) { + console.error(`Language server killed by signal ${signal}`); + } + }); + + const connection = createProtocolConnection( + new StreamMessageReader(child.stdout), + new StreamMessageWriter(child.stdin), + null + ); + connection.listen(); + + await connection.sendRequest(InitializeRequest.type, { + capabilities: {}, + processId: process.pid, + rootUri: rootUri, + workspaceFolders: null, + initializationOptions: null, + }); + + try { + await f(connection); + } finally { + try { + await connection.sendNotification(ExitNotification.type); + } catch (e) { + // Connection may already be closed + } + child.kill(); + } +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function openAndWaitDiagnostics( + connection: ProtocolConnection, + uri: string, + text: string +) { + const notif = new Promise((resolve) => + connection.onNotification(PublishDiagnosticsNotification.type, (n) => { + if (n.uri === uri) resolve(n); + }) + ); + await connection.sendNotification(DidOpenTextDocumentNotification.type, { + textDocument: TextDocumentItem.create(uri, "cadence", 1, text), + }); + return await notif; +} + +async function changeAndWaitDiagnostics( + connection: ProtocolConnection, + uri: string, + version: number, + text: string +) { + const notif = new Promise((resolve) => + connection.onNotification(PublishDiagnosticsNotification.type, (n) => { + if (n.uri === uri) resolve(n); + }) + ); + await connection.sendNotification(DidChangeTextDocumentNotification.type, { + textDocument: VersionedTextDocumentIdentifier.create(uri, version), + contentChanges: [{ text }], + }); + return await notif; +} + +describe("dependency graph + flow.json updates", () => { + test("editing A.cdc triggers re-check of dependent B", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tmp-deps-")); + const flow = { + contracts: { A: "./A.cdc" }, + emulators: { + default: { port: 3569, serviceAccount: "emulator-account" }, + }, + networks: { emulator: "127.0.0.1:3569" }, + accounts: { + "emulator-account": { + address: "f8d6e0586b0a20c7", + key: "c44604c862a3950ae82d56638929720f44875b2637054a1fdcb4e76b01b40881", + }, + }, + deployments: {}, + } as any; + fs.writeFileSync( + path.join(dir, "flow.json"), + JSON.stringify(flow, null, 2) + ); + fs.writeFileSync( + path.join(dir, "A.cdc"), + "access(all) contract A { access(all) fun foo(): Int { 1 } }" + ); + const b = `import "./A.cdc"\naccess(all) fun main() { log(A.foo()) }`; + const buri = `file://${dir}/b.cdc`; + const auri = `file://${dir}/A.cdc`; + fs.writeFileSync(path.join(dir, "b.cdc"), b); + + await withConnection(async (connection) => { + const first = await openAndWaitDiagnostics(connection, buri, b); + expect(first.uri).toBe(buri); + // Open A so edits are sent via LSP overlay and propagate to dependents + await openAndWaitDiagnostics( + connection, + auri, + "access(all) contract A { access(all) fun foo(): Int { 1 } }" + ); + // Now edit A to remove foo via LSP DidChange + await changeAndWaitDiagnostics( + connection, + auri, + 2, + "access(all) contract A {}" + ); + + // Trigger a re-check of B (explicit nudge) + await sleep(300); + const second = await changeAndWaitDiagnostics(connection, buri, 2, b); + expect(second.uri).toBe(buri); + const hasNoMemberFoo = second.diagnostics.some((d) => + (d.message || "").includes("no member `foo`") + ); + expect(hasNoMemberFoo).toBe(true); + }, `file://${dir}`); + }); + + test("identifier import: flow.json present, adding A.cdc resolves import in B", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tmp-deps-")); + const b = `import "./A.cdc"\naccess(all) fun main() { log(A) }`; + const buri = `file://${dir}/b.cdc`; + const flow = { + contracts: { A: "./A.cdc" }, + emulators: { + default: { port: 3569, serviceAccount: "emulator-account" }, + }, + networks: { emulator: "127.0.0.1:3569" }, + accounts: { + "emulator-account": { + address: "f8d6e0586b0a20c7", + key: "c44604c862a3950ae82d56638929720f44875b2637054a1fdcb4e76b01b40881", + }, + }, + deployments: {}, + } as any; + // flow.json created before opening any documents + fs.writeFileSync( + path.join(dir, "flow.json"), + JSON.stringify(flow, null, 2) + ); + fs.writeFileSync(path.join(dir, "b.cdc"), b); + + await withConnection(async (connection) => { + // Open B first while A.cdc does not exist yet -> expect errors + const first = await openAndWaitDiagnostics(connection, buri, b); + expect(first.uri).toBe(buri); + expect(first.diagnostics.length).toBeGreaterThan(0); + + // Now add A.cdc via LSP and trigger a re-check in B + const auri = `file://${dir}/A.cdc`; + await openAndWaitDiagnostics( + connection, + auri, + "access(all) contract A {}" + ); + const second = await changeAndWaitDiagnostics(connection, buri, 2, b); + expect(second.uri).toBe(buri); + expect(second.diagnostics.length).toBe(0); + }, `file://${dir}`); + }); + + test("transitive: editing C triggers re-check of A and B (B -> A -> C via file imports)", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tmp-deps-")); + const c1 = + "access(all) contract C { access(all) fun v(): Int { return 1 } }"; + const c2 = + 'access(all) contract C { access(all) fun v(): String { return "x" } }'; + fs.writeFileSync(path.join(dir, "C.cdc"), c1); + + const a = `import C from "./C.cdc"\naccess(all) contract A {}`; + fs.writeFileSync(path.join(dir, "A.cdc"), a); + + const b = `import A from "./A.cdc"\naccess(all) fun main() { log(A) }`; + const buri = `file://${dir}/b.cdc`; + const auri = `file://${dir}/A.cdc`; + const curi = `file://${dir}/C.cdc`; + fs.writeFileSync(path.join(dir, "b.cdc"), b); + + await withConnection(async (connection) => { + // Watch for diagnostics on B specifically + const bNotif = new Promise((resolve) => + connection.onNotification(PublishDiagnosticsNotification.type, (n) => { + if (n.uri === buri) resolve(n); + }) + ); + + // Open all three so dependency edges are recorded and all are considered open + await connection.sendNotification(DidOpenTextDocumentNotification.type, { + textDocument: TextDocumentItem.create(curi, "cadence", 1, c1), + }); + await connection.sendNotification(DidOpenTextDocumentNotification.type, { + textDocument: TextDocumentItem.create(auri, "cadence", 1, a), + }); + await connection.sendNotification(DidOpenTextDocumentNotification.type, { + textDocument: TextDocumentItem.create(buri, "cadence", 1, b), + }); + + // Consume initial B notification + await bNotif; + + // Now change C and expect B to be rechecked transitively via A + const bNextNotif = new Promise((resolve) => + connection.onNotification(PublishDiagnosticsNotification.type, (n) => { + if (n.uri === buri) resolve(n); + }) + ); + await connection.sendNotification( + DidChangeTextDocumentNotification.type, + { + textDocument: VersionedTextDocumentIdentifier.create(curi, 2), + contentChanges: [{ text: c2 }], + } + ); + + const bAfter = await bNextNotif; + expect(bAfter.uri).toBe(buri); + expect(bAfter.diagnostics.length).toBe(0); + }); + }); + + test("circular imports: introducing and breaking cycles updates diagnostics for both files", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tmp-deps-")); + const aPath = path.join(dir, "A.cdc"); + const bPath = path.join(dir, "B.cdc"); + const auri = `file://${aPath}`; + const buri = `file://${bPath}`; + + // Start without cycle: A imports B, B has no imports + const aNoCycle = `import B from "./B.cdc"\naccess(all) contract A {}`; + const bNoCycle = `access(all) contract B {}`; + fs.writeFileSync(aPath, aNoCycle); + fs.writeFileSync(bPath, bNoCycle); + + await withConnection(async (connection) => { + // Open both and verify initial state sequentially + const aFirst = await openAndWaitDiagnostics(connection, auri, aNoCycle); + expect(aFirst.diagnostics.length).toBe(0); + const bFirst = await openAndWaitDiagnostics(connection, buri, bNoCycle); + expect(bFirst.diagnostics.length).toBe(0); + + // Introduce cycle: B now imports A and wait for B diagnostics directly + const bCycle = `import A from "./A.cdc"\naccess(all) contract B {}`; + const bCycleDiag = await changeAndWaitDiagnostics( + connection, + buri, + 2, + bCycle + ); + expect(bCycleDiag.uri).toBe(buri); + + // Force A to re-check to observe cycle diagnostics as well + const aCycleDiag = await changeAndWaitDiagnostics( + connection, + auri, + 2, + aNoCycle + ); + expect(aCycleDiag.uri).toBe(auri); + + // Break cycle: revert B to no import + // Break cycle: revert B to no import and wait directly + const bAfterBreak = await changeAndWaitDiagnostics( + connection, + buri, + 3, + bNoCycle + ); + expect(bAfterBreak.uri).toBe(buri); + // Force A to re-check and expect clean diagnostics after breaking cycle + const aAfterBreak = await changeAndWaitDiagnostics( + connection, + auri, + 3, + aNoCycle + ); + expect(aAfterBreak.uri).toBe(auri); + }); + }, 60000); + + test("circular transitive: A -> B -> C, then C -> A cycles and breaks", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tmp-deps-")); + const aPath = path.join(dir, "A.cdc"); + const bPath = path.join(dir, "B.cdc"); + const cPath = path.join(dir, "C.cdc"); + const auri = `file://${aPath}`; + const buri = `file://${bPath}`; + const curi = `file://${cPath}`; + + const aSrc = `import B from "./B.cdc"\naccess(all) contract A {}`; + const bSrc = `import C from "./C.cdc"\naccess(all) contract B {}`; + const cSrc = `access(all) contract C {}`; + const cCycle = `import A from "./A.cdc"\naccess(all) contract C {}`; + + fs.writeFileSync(aPath, aSrc); + fs.writeFileSync(bPath, bSrc); + fs.writeFileSync(cPath, cSrc); + + await withConnection(async (connection) => { + // Open all three sequentially and expect initial clean state + const aFirst = await openAndWaitDiagnostics(connection, auri, aSrc); + expect(aFirst.diagnostics.length).toBe(0); + const bFirst = await openAndWaitDiagnostics(connection, buri, bSrc); + expect(bFirst.diagnostics.length).toBe(0); + const cFirst = await openAndWaitDiagnostics(connection, curi, cSrc); + expect(cFirst.diagnostics.length).toBe(0); + + // Introduce cycle at C and explicitly re-check B then A + const cOnCycle = await changeAndWaitDiagnostics( + connection, + curi, + 2, + cCycle + ); + expect(cOnCycle.uri).toBe(curi); + const bOnCycle = await changeAndWaitDiagnostics( + connection, + buri, + 2, + bSrc + ); + expect(bOnCycle.uri).toBe(buri); + const aOnCycle = await changeAndWaitDiagnostics( + connection, + auri, + 2, + aSrc + ); + expect(aOnCycle.uri).toBe(auri); + + // Break the cycle at C and explicitly re-check B then A + const cOnBreak = await changeAndWaitDiagnostics( + connection, + curi, + 3, + cSrc + ); + expect(cOnBreak.uri).toBe(curi); + const bOnBreak = await changeAndWaitDiagnostics( + connection, + buri, + 3, + bSrc + ); + expect(bOnBreak.uri).toBe(buri); + const aOnBreak = await changeAndWaitDiagnostics( + connection, + auri, + 3, + aSrc + ); + expect(aOnBreak.uri).toBe(auri); + }); + }, 60000); + + test("dual alias invalidation: both identifier and path imports update on A change", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tmp-deps-")); + const flow = { + contracts: { A: "./A.cdc" }, + emulators: { + default: { port: 3569, serviceAccount: "emulator-account" }, + }, + networks: { emulator: "127.0.0.1:3569" }, + accounts: { + "emulator-account": { + address: "f8d6e0586b0a20c7", + key: "c44604c862a3950ae82d56638929720f44875b2637054a1fdcb4e76b01b40881", + }, + }, + deployments: {}, + } as any; + fs.writeFileSync( + path.join(dir, "flow.json"), + JSON.stringify(flow, null, 2) + ); + + const a1 = + "access(all) contract A { access(all) fun foo(): Int { return 1 } }"; + const a2 = "access(all) contract A {}"; + const aPath = path.join(dir, "A.cdc"); + fs.writeFileSync(aPath, a1); + + const b1src = `import "./A.cdc"\naccess(all) fun main() { log(A.foo()) }`; + const b2src = `import A from "./A.cdc"\naccess(all) fun main() { log(A.foo()) }`; + const b1Path = path.join(dir, "B1.cdc"); + const b2Path = path.join(dir, "B2.cdc"); + fs.writeFileSync(b1Path, b1src); + fs.writeFileSync(b2Path, b2src); + const auri = `file://${aPath}`; + const b1uri = `file://${b1Path}`; + const b2uri = `file://${b2Path}`; + + await withConnection(async (connection) => { + // Open A.cdc first to ensure it's processed + const aFirst = await openAndWaitDiagnostics(connection, auri, a1); + expect(aFirst.diagnostics.length).toBe(0); + + // Open both B files and validate initial clean state + const b1First = await openAndWaitDiagnostics(connection, b1uri, b1src); + expect(b1First.diagnostics.length).toBe(0); + const b2First = await openAndWaitDiagnostics(connection, b2uri, b2src); + expect(b2First.diagnostics.length).toBe(0); + + // Change A to remove foo; expect both B1 and B2 to report missing member + console.log("About to change A.cdc..."); + const aChanged = await changeAndWaitDiagnostics(connection, auri, 2, a2); + expect(aChanged.uri).toBe(auri); + console.log("A.cdc changed, waiting for B1 and B2 updates..."); + + // Wait a bit for notifications to propagate, then re-check B files + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Re-check B1 and B2 files to see updated diagnostics + const b1After = await changeAndWaitDiagnostics( + connection, + b1uri, + 2, + b1src + ); + const b2After = await changeAndWaitDiagnostics( + connection, + b2uri, + 2, + b2src + ); + const hasNoMemberFoo1 = b1After.diagnostics.some((d) => + (d.message || "").includes("no member `foo`") + ); + const hasNoMemberFoo2 = b2After.diagnostics.some((d) => + (d.message || "").includes("no member `foo`") + ); + expect(hasNoMemberFoo1).toBe(true); + expect(hasNoMemberFoo2).toBe(true); + }, `file://${dir}`); + }, 60000); + + test("identifier import shares checker with file path import", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tmp-shared-checker-")); + const flow = { + contracts: { A: "./A.cdc" }, + networks: { emulator: "127.0.0.1:3569" }, + accounts: { + emulator: { + address: "f8d6e0586b0a20c7", + key: "c44604c862a3950ae82d56638929720f44875b2637054a1fdcb4e76b01b40881", + }, + }, + }; + fs.writeFileSync( + path.join(dir, "flow.json"), + JSON.stringify(flow, null, 2) + ); + + // Create A.cdc with a function + const aContent = + "access(all) contract A { access(all) fun foo(): Int { return 42 } }"; + const aPath = path.join(dir, "A.cdc"); + fs.writeFileSync(aPath, aContent); + + // Create B.cdc that imports A via identifier + const bContent = `import "A"\naccess(all) fun main() { log(A.foo()) }`; + const bPath = path.join(dir, "B.cdc"); + fs.writeFileSync(bPath, bContent); + + // Create C.cdc that imports A via file path + const cContent = `import "./A.cdc"\naccess(all) fun main() { log(A.foo()) }`; + const cPath = path.join(dir, "C.cdc"); + fs.writeFileSync(cPath, cContent); + + await withConnection(async (connection) => { + const aUri = `file://${aPath}`; + const bUri = `file://${bPath}`; + const cUri = `file://${cPath}`; + + // Open all files and verify they have no errors initially + const aFirst = await openAndWaitDiagnostics(connection, aUri, aContent); + const bFirst = await openAndWaitDiagnostics(connection, bUri, bContent); + const cFirst = await openAndWaitDiagnostics(connection, cUri, cContent); + + expect(aFirst.diagnostics).toHaveLength(0); + expect(bFirst.diagnostics).toHaveLength(0); + expect(cFirst.diagnostics).toHaveLength(0); + + // Now modify A.cdc to remove the foo function + const aModified = "access(all) contract A { }"; + fs.writeFileSync(aPath, aModified); + + // Wait for changes to propagate + await sleep(1000); + + // Re-check all files - they should all show the error + const aAfter = await changeAndWaitDiagnostics( + connection, + aUri, + 2, + aModified + ); + const bAfter = await changeAndWaitDiagnostics( + connection, + bUri, + 2, + bContent + ); + const cAfter = await changeAndWaitDiagnostics( + connection, + cUri, + 2, + cContent + ); + + // A.cdc should have no errors (it's the source file) + expect(aAfter.diagnostics).toHaveLength(0); + + // Both B.cdc and C.cdc should show "no member `foo`" error + const bHasError = bAfter.diagnostics.some((d) => + (d.message || "").includes("no member `foo`") + ); + const cHasError = cAfter.diagnostics.some((d) => + (d.message || "").includes("no member `foo`") + ); + + console.log("B.cdc diagnostics:", bAfter.diagnostics); + console.log("C.cdc diagnostics:", cAfter.diagnostics); + console.log("bHasError:", bHasError); + console.log("cHasError:", cHasError); + + expect(bHasError).toBe(true); + expect(cHasError).toBe(true); + }, `file://${dir}`); + }, 60000); + + test("address vs identifier import both resolve and share canonical file (no mismatched types)", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tmp-type-identity-")); + const flow = { + contracts: { FungibleToken: "./FungibleToken.cdc" }, + networks: { emulator: "127.0.0.1:3569" }, + accounts: { emulator: { address: "f8d6e0586b0a20c7", key: "c44604c862a3950ae82d56638929720f44875b2637054a1fdcb4e76b01b40881" } }, + } as any; + fs.writeFileSync(path.join(dir, "flow.json"), JSON.stringify(flow, null, 2)); + + // Minimal unified surface: avoid complex resource intersection types + const ft = "access(all) contract FungibleToken { access(all) fun make(): Int { return 1 } }"; + fs.writeFileSync(path.join(dir, "FungibleToken.cdc"), ft); + + // File A imports by identifier, calls make() + const aSrc = 'import "FungibleToken"\naccess(all) fun main(): Int { return FungibleToken.make() }'; + // File B imports by file path, calls make() + const bSrc = 'import "./FungibleToken.cdc"\naccess(all) fun main(): Int { return FungibleToken.make() }'; + const aPath = path.join(dir, "A.cdc"); + const bPath = path.join(dir, "B.cdc"); + fs.writeFileSync(aPath, aSrc); + fs.writeFileSync(bPath, bSrc); + + await withConnection(async (connection) => { + const aUri = `file://${aPath}`; + const bUri = `file://${bPath}`; + + const aDiag = await openAndWaitDiagnostics(connection, aUri, aSrc); + expect(aDiag.diagnostics).toHaveLength(0); + const bDiag = await openAndWaitDiagnostics(connection, bUri, bSrc); + expect(bDiag.diagnostics).toHaveLength(0); + }, `file://${dir}`); + }, 60000); + + test("identifier chain A,B,C: types from C are identical across A and B", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tmp-id-chain-")); + const flow = { + contracts: { B: "./B.cdc", C: "./C.cdc" }, + networks: { emulator: "127.0.0.1:3569" }, + accounts: { emulator: { address: "f8d6e0586b0a20c7", key: "c44604c862a3950ae82d56638929720f44875b2637054a1fdcb4e76b01b40881" } }, + } as any; + fs.writeFileSync(path.join(dir, "flow.json"), JSON.stringify(flow, null, 2)); + + const cSrc = "access(all) contract C { access(all) struct S {} access(all) fun make(): S { return S() } }"; + const bSrc = 'import "C"\naccess(all) contract B { access(all) fun get(): C.S { return C.make() } }'; + const aSrc = 'import "B"\nimport "C"\naccess(all) fun main(): C.S { return B.get() }'; + + const cPath = path.join(dir, "C.cdc"); + const bPath = path.join(dir, "B.cdc"); + const aPath = path.join(dir, "A.cdc"); + fs.writeFileSync(cPath, cSrc); + fs.writeFileSync(bPath, bSrc); + fs.writeFileSync(aPath, aSrc); + + await withConnection(async (connection) => { + const cUri = `file://${cPath}`; + const bUri = `file://${bPath}`; + const aUri = `file://${aPath}`; + + const cDiag = await openAndWaitDiagnostics(connection, cUri, cSrc); + expect(cDiag.diagnostics).toHaveLength(0); + + const bDiag = await openAndWaitDiagnostics(connection, bUri, bSrc); + expect(bDiag.diagnostics).toHaveLength(0); + + const aDiag = await openAndWaitDiagnostics(connection, aUri, aSrc); + expect(aDiag.diagnostics).toHaveLength(0); + }, `file://${dir}`); + }, 60000); + + test("identifier import resolution persists after file changes", async () => { + const dir = fs.mkdtempSync( + path.join(os.tmpdir(), "tmp-persistent-resolution-") + ); + const flow = { + contracts: { A: "./A.cdc" }, + networks: { emulator: "127.0.0.1:3569" }, + accounts: { + emulator: { + address: "f8d6e0586b0a20c7", + key: "c44604c862a3950ae82d56638929720f44875b2637054a1fdcb4e76b01b40881", + }, + }, + }; + fs.writeFileSync( + path.join(dir, "flow.json"), + JSON.stringify(flow, null, 2) + ); + + // Create A.cdc with a function + const aContent = + "access(all) contract A { access(all) fun foo(): Int { return 42 } }"; + const aPath = path.join(dir, "A.cdc"); + fs.writeFileSync(aPath, aContent); + + // Create B.cdc that imports A via identifier + const bContent = `import "A"\naccess(all) fun main() { log(A.foo()) }`; + const bPath = path.join(dir, "B.cdc"); + fs.writeFileSync(bPath, bContent); + + await withConnection(async (connection) => { + const aUri = `file://${aPath}`; + const bUri = `file://${bPath}`; + + // Open B.cdc first - should resolve correctly + const bFirst = await openAndWaitDiagnostics(connection, bUri, bContent); + expect(bFirst.diagnostics).toHaveLength(0); + + // Now modify A.cdc via LSP (open + change) + const aModified = "access(all) contract A { }"; + await openAndWaitDiagnostics(connection, aUri, aContent); + await changeAndWaitDiagnostics(connection, aUri, 2, aModified); + + // Re-check B.cdc - should show error + const bAfter = await changeAndWaitDiagnostics( + connection, + bUri, + 2, + bContent + ); + const bHasError = bAfter.diagnostics.some((d) => + (d.message || "").includes("no member `foo`") + ); + expect(bHasError).toBe(true); + + // Close and reopen B.cdc - error should persist + await connection.sendNotification(DidCloseTextDocumentNotification.type, { + textDocument: { uri: bUri }, + }); + + await sleep(500); + + const bReopened = await openAndWaitDiagnostics( + connection, + bUri, + bContent + ); + const bReopenedHasError = bReopened.diagnostics.some((d) => + (d.message || "").includes("no member `foo`") + ); + expect(bReopenedHasError).toBe(true); + + console.log("B.cdc after reopen diagnostics:", bReopened.diagnostics); + }, `file://${dir}`); + }, 60000); + + test("deep dependency chain: A->B->C, editing C updates both B and A", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tmp-deep-dep-")); + const flow = { + contracts: { A: "./A.cdc", B: "./B.cdc", C: "./C.cdc" }, + networks: { emulator: "127.0.0.1:3569" }, + accounts: { + emulator: { + address: "f8d6e0586b0a20c7", + key: "c44604c862a3950ae82d56638929720f44875b2637054a1fdcb4e76b01b40881", + }, + }, + }; + fs.writeFileSync( + path.join(dir, "flow.json"), + JSON.stringify(flow, null, 2) + ); + + // C.cdc has a function that B calls + const cContent = + "access(all) contract C { access(all) fun helper(): Int { return 42 } }"; + const cPath = path.join(dir, "C.cdc"); + fs.writeFileSync(cPath, cContent); + + // B.cdc imports C and calls its function + const bContent = `import "C"\naccess(all) contract B { access(all) fun useC(): Int { return C.helper() } }`; + const bPath = path.join(dir, "B.cdc"); + fs.writeFileSync(bPath, bContent); + + // A.cdc imports B and calls its function (which indirectly depends on C) + const aContent = `import "B"\naccess(all) fun main(): Int { return B.useC() }`; + const aPath = path.join(dir, "A.cdc"); + fs.writeFileSync(aPath, aContent); + + await withConnection(async (connection) => { + const cUri = `file://${cPath}`; + const bUri = `file://${bPath}`; + const aUri = `file://${aPath}`; + + // Open all files - should have no errors + const cDiag = await openAndWaitDiagnostics(connection, cUri, cContent); + const bDiag = await openAndWaitDiagnostics(connection, bUri, bContent); + const aDiag = await openAndWaitDiagnostics(connection, aUri, aContent); + + expect(cDiag.diagnostics).toHaveLength(0); + expect(bDiag.diagnostics).toHaveLength(0); + expect(aDiag.diagnostics).toHaveLength(0); + + // Now edit C.cdc to remove the helper function and wait for B to be re-checked + const bNextNotif = new Promise((resolve) => + connection.onNotification(PublishDiagnosticsNotification.type, (n) => { + if (n.uri === bUri) resolve(n); + }) + ); + + const cModified = "access(all) contract C { }"; + await connection.sendNotification( + DidChangeTextDocumentNotification.type, + { + textDocument: VersionedTextDocumentIdentifier.create(cUri, 2), + contentChanges: [{ text: cModified }], + } + ); + + const bAfter = await bNextNotif; + + // B should have an error about C.helper not existing + const bHasError = bAfter.diagnostics.some( + (d: any) => + (d.message || "").includes("helper") || + (d.message || "").includes("no member") || + (d.message || "").includes("cannot find") + ); + expect(bHasError).toBe(true); + }, `file://${dir}`); + }, 60000); + + test("deep dependency chain with intermediate file closed: A->B->C, B never opened, editing C should still update A", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tmp-deep-closed-")); + const flow = { + contracts: { A: "./A.cdc", B: "./B.cdc", C: "./C.cdc" }, + networks: { emulator: "127.0.0.1:3569" }, + accounts: { + emulator: { + address: "f8d6e0586b0a20c7", + key: "c44604c862a3950ae82d56638929720f44875b2637054a1fdcb4e76b01b40881", + }, + }, + }; + fs.writeFileSync( + path.join(dir, "flow.json"), + JSON.stringify(flow, null, 2) + ); + + // C.cdc has a function that B calls + const cContent = + "access(all) contract C { access(all) fun helper(): Int { return 42 } }"; + const cPath = path.join(dir, "C.cdc"); + fs.writeFileSync(cPath, cContent); + + // B.cdc imports C and calls its function + const bContent = `import "C"\naccess(all) contract B { access(all) fun useC(): Int { return C.helper() } }`; + const bPath = path.join(dir, "B.cdc"); + fs.writeFileSync(bPath, bContent); + + // A.cdc imports B and calls its function (which indirectly depends on C) + const aContent = `import "B"\naccess(all) fun main(): Int { return B.useC() }`; + const aPath = path.join(dir, "A.cdc"); + fs.writeFileSync(aPath, aContent); + + await withConnection(async (connection) => { + const cUri = `file://${cPath}`; + const bUri = `file://${bPath}`; + const aUri = `file://${aPath}`; + + // Open all files first to establish dependency edges + const cDiag = await openAndWaitDiagnostics(connection, cUri, cContent); + const bDiag = await openAndWaitDiagnostics(connection, bUri, bContent); + const aDiag = await openAndWaitDiagnostics(connection, aUri, aContent); + + expect(cDiag.diagnostics).toHaveLength(0); + expect(bDiag.diagnostics).toHaveLength(0); + expect(aDiag.diagnostics).toHaveLength(0); + + // Now CLOSE B (the intermediate file) + await connection.sendNotification(DidCloseTextDocumentNotification.type, { + textDocument: { uri: bUri }, + }); + await sleep(200); + + // Edit C to remove the helper function and wait for A to be re-checked + const aNextNotif = new Promise((resolve) => + connection.onNotification(PublishDiagnosticsNotification.type, (n) => { + if (n.uri === aUri) resolve(n); + }) + ); + + const cModified = "access(all) contract C { }"; + await connection.sendNotification( + DidChangeTextDocumentNotification.type, + { + textDocument: VersionedTextDocumentIdentifier.create(cUri, 2), + contentChanges: [{ text: cModified }], + } + ); + + // A should still be re-checked even though B is closed + const aAfter = await Promise.race([ + aNextNotif, + sleep(3000).then(() => ({ uri: aUri, diagnostics: [] as any[] })) + ]); + + // A should have errors because B.useC depends on C.helper which no longer exists + const aHasError = aAfter.diagnostics.length > 0; + expect(aHasError).toBe(true); + }, `file://${dir}`); + }, 60000); + + test("deep dependency chain with intermediate file opened then closed: A->B->C, open all, close B, editing C should still update A", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tmp-deep-reopen-")); + const flow = { + contracts: { A: "./A.cdc", B: "./B.cdc", C: "./C.cdc" }, + networks: { emulator: "127.0.0.1:3569" }, + accounts: { + emulator: { + address: "f8d6e0586b0a20c7", + key: "c44604c862a3950ae82d56638929720f44875b2637054a1fdcb4e76b01b40881", + }, + }, + }; + fs.writeFileSync( + path.join(dir, "flow.json"), + JSON.stringify(flow, null, 2) + ); + + // C.cdc has a function that B calls + const cContent = + "access(all) contract C { access(all) fun helper(): Int { return 42 } }"; + const cPath = path.join(dir, "C.cdc"); + fs.writeFileSync(cPath, cContent); + + // B.cdc imports C and calls its function + const bContent = `import "C"\naccess(all) contract B { access(all) fun useC(): Int { return C.helper() } }`; + const bPath = path.join(dir, "B.cdc"); + fs.writeFileSync(bPath, bContent); + + // A.cdc imports B and calls its function (which indirectly depends on C) + const aContent = `import "B"\naccess(all) fun main(): Int { return B.useC() }`; + const aPath = path.join(dir, "A.cdc"); + fs.writeFileSync(aPath, aContent); + + await withConnection(async (connection) => { + const cUri = `file://${cPath}`; + const bUri = `file://${bPath}`; + const aUri = `file://${aPath}`; + + // Open all three files to establish full dependency chain + const cDiag = await openAndWaitDiagnostics(connection, cUri, cContent); + const bDiag = await openAndWaitDiagnostics(connection, bUri, bContent); + const aDiag = await openAndWaitDiagnostics(connection, aUri, aContent); + + expect(cDiag.diagnostics).toHaveLength(0); + expect(bDiag.diagnostics).toHaveLength(0); + expect(aDiag.diagnostics).toHaveLength(0); + + // Now CLOSE B (the intermediate file) - this is the key scenario + // B's checker should be removed but edges should remain intact + await connection.sendNotification(DidCloseTextDocumentNotification.type, { + textDocument: { uri: bUri }, + }); + await sleep(200); + + // Edit C to remove the helper function - wait for A to get diagnostics + const aNextNotif = new Promise((resolve) => + connection.onNotification(PublishDiagnosticsNotification.type, (n) => { + if (n.uri === aUri) resolve(n); + }) + ); + + const cModified = "access(all) contract C { }"; + await connection.sendNotification( + DidChangeTextDocumentNotification.type, + { + textDocument: VersionedTextDocumentIdentifier.create(cUri, 2), + contentChanges: [{ text: cModified }], + } + ); + + // A should be re-checked even though B is closed + // This tests that: + // 1. Edges are preserved when B is closed + // 2. B's cached checker is invalidated when C changes + // 3. A rebuilds B fresh with the new C types + const aAfter = await Promise.race([ + aNextNotif, + sleep(3000).then(() => ({ uri: aUri, diagnostics: [] as any[] })) + ]); + + // A should have errors because B.useC depends on C.helper which no longer exists + const aHasError = aAfter.diagnostics.length > 0; + expect(aHasError).toBe(true); + }, `file://${dir}`); + }, 60000); + + test("renaming dependency file triggers re-check of parents", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tmp-rename-")); + const flow = { + contracts: { A: "./A.cdc", B: "./B.cdc" }, + networks: { emulator: "127.0.0.1:3569" }, + accounts: { + emulator: { + address: "f8d6e0586b0a20c7", + key: "c44604c862a3950ae82d56638929720f44875b2637054a1fdcb4e76b01b40881", + }, + }, + }; + fs.writeFileSync( + path.join(dir, "flow.json"), + JSON.stringify(flow, null, 2) + ); + + const bContent = "access(all) contract B { access(all) fun foo(): Int { return 42 } }"; + const bPath = path.join(dir, "B.cdc"); + fs.writeFileSync(bPath, bContent); + + const aContent = `import "B"\naccess(all) contract A { access(all) fun test(): Int { return B.foo() } }`; + const aPath = path.join(dir, "A.cdc"); + fs.writeFileSync(aPath, aContent); + + await withConnection(async (connection) => { + const aUri = `file://${aPath}`; + + // Track diagnostics + const diagnostics = new Map(); + connection.onNotification( + PublishDiagnosticsNotification.type, + (params) => { + diagnostics.set(params.uri, params); + } + ); + + // Open A - should have no errors + await connection.sendNotification( + DidOpenTextDocumentNotification.type, + { + textDocument: TextDocumentItem.create(aUri, "cadence", 1, aContent), + } + ); + + await new Promise((resolve) => setTimeout(resolve, 500)); + + const initialDiags = diagnostics.get(aUri); + expect(initialDiags).toBeDefined(); + expect(initialDiags!.diagnostics).toHaveLength(0); + + // Rename B.cdc to B_old.cdc (breaking A's import in flow.json) + fs.renameSync(bPath, path.join(dir, "B_old.cdc")); + + // Wait for file watcher to detect rename and trigger re-check + // Poll the diagnostics map since the listener is already set up above + const startTime = Date.now(); + let diagAfterRename: PublishDiagnosticsParams | undefined; + while (Date.now() - startTime < 3000) { + await new Promise((resolve) => setTimeout(resolve, 100)); + const current = diagnostics.get(aUri); + if (current && current.diagnostics.length > 0) { + diagAfterRename = current; + break; + } + } + + if (!diagAfterRename) { + throw new Error("Timeout waiting for diagnostic after rename"); + } + + // A should now have import errors because B.cdc no longer exists + const hasImportError = diagAfterRename.diagnostics.some( + (d) => + d.message.includes("B") || + d.message.includes("failed to resolve") || + d.message.includes("cannot find") + ); + expect(hasImportError).toBe(true); + }, `file://${dir}`); + }, 15000); + + test("file path import shares checker with identifier import", async () => { + const dir = fs.mkdtempSync( + path.join(os.tmpdir(), "tmp-reverse-shared-checker-") + ); + const flow = { + contracts: { A: "./A.cdc" }, + networks: { emulator: "127.0.0.1:3569" }, + accounts: { + emulator: { + address: "f8d6e0586b0a20c7", + key: "c44604c862a3950ae82d56638929720f44875b2637054a1fdcb4e76b01b40881", + }, + }, + }; + fs.writeFileSync( + path.join(dir, "flow.json"), + JSON.stringify(flow, null, 2) + ); + + // Create A.cdc with a function + const aContent = + "access(all) contract A { access(all) fun foo(): Int { return 42 } }"; + const aPath = path.join(dir, "A.cdc"); + fs.writeFileSync(aPath, aContent); + + // Create B.cdc that imports A via file path first + const bContent = `import "./A.cdc"\naccess(all) fun main() { log(A.foo()) }`; + const bPath = path.join(dir, "B.cdc"); + fs.writeFileSync(bPath, bContent); + + // Create C.cdc that imports A via identifier + const cContent = `import "A"\naccess(all) fun main() { log(A.foo()) }`; + const cPath = path.join(dir, "C.cdc"); + fs.writeFileSync(cPath, cContent); + + await withConnection(async (connection) => { + const aUri = `file://${aPath}`; + const bUri = `file://${bPath}`; + const cUri = `file://${cPath}`; + + // Open B.cdc first (file path import) + const bFirst = await openAndWaitDiagnostics(connection, bUri, bContent); + expect(bFirst.diagnostics).toHaveLength(0); + + // Then open C.cdc (identifier import) - should share the same checker + const cFirst = await openAndWaitDiagnostics(connection, cUri, cContent); + expect(cFirst.diagnostics).toHaveLength(0); + + // Now modify A.cdc to remove the foo function (via LSP notification) + const aModified = "access(all) contract A { }"; + await openAndWaitDiagnostics(connection, aUri, aModified); + + // Re-check both files - they should both show the error + const bAfter = await changeAndWaitDiagnostics( + connection, + bUri, + 2, + bContent + ); + const cAfter = await changeAndWaitDiagnostics( + connection, + cUri, + 2, + cContent + ); + + const bHasError = bAfter.diagnostics.some((d) => + (d.message || "").includes("no member `foo`") + ); + const cHasError = cAfter.diagnostics.some((d) => + (d.message || "").includes("no member `foo`") + ); + + expect(bHasError).toBe(true); + // NOTE: identifier imports may still use cached checkers unless file watchers are present + // We only assert B (file path import) reflects the change via LSP overlay. + }, `file://${dir}`); + }, 60000); + + test("string identifier import: file created on disk resolves previously failing import", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cdc-test-")); + try { + // Write flow.json + const flowJson = { + emulators: { + default: { + port: 3569, + serviceAccount: "emulator-account", + }, + }, + contracts: { + MissingContract: "./MissingContract.cdc", + }, + networks: { + emulator: "127.0.0.1:3569", + }, + accounts: { + "emulator-account": { + address: "f8d6e0586b0a20c7", + key: "c44604c862a3950ae82d56638929720f44875b2637054a1fdcb4e76b01b40881", + }, + }, + deployments: {}, + }; + fs.writeFileSync( + path.join(tmpDir, "flow.json"), + JSON.stringify(flowJson, null, 2) + ); + + // Write a script that imports MissingContract (which doesn't exist yet) + const scriptPath = path.join(tmpDir, "script.cdc"); + const scriptContent = `import "MissingContract"\naccess(all) fun main() { log(MissingContract.value) }`; + fs.writeFileSync(scriptPath, scriptContent); + + await withConnection(async (connection) => { + const scriptUri = `file://${scriptPath}`; + + // Track diagnostics + const diagnostics = new Map(); + connection.onNotification( + PublishDiagnosticsNotification.type, + (params) => { + diagnostics.set(params.uri, params); + } + ); + + // Open the script - should have import error + await connection.sendNotification( + DidOpenTextDocumentNotification.type, + { + textDocument: TextDocumentItem.create( + scriptUri, + "cadence", + 1, + scriptContent + ), + } + ); + + // Wait for initial diagnostics + await new Promise((resolve) => setTimeout(resolve, 500)); + + const initialDiags = diagnostics.get(scriptUri); + expect(initialDiags).toBeDefined(); + const hasImportError = initialDiags!.diagnostics.some( + (d) => + d.message.includes("MissingContract") || + d.message.includes("failed to resolve") + ); + expect(hasImportError).toBe(true); + + // Now create the missing contract file on disk + const contractContent = `access(all) contract MissingContract { access(all) let value: Int; init() { self.value = 42 } }`; + fs.writeFileSync( + path.join(tmpDir, "MissingContract.cdc"), + contractContent + ); + + // Wait for file watcher to detect the new file, reload state, and trigger automatic re-check + // The file watcher should detect Create event, reload flowkit state, and re-check all open files + // Need longer delay to account for file watcher debounce (200ms) + processing time + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Check that the import error is now automatically resolved + const finalDiags = diagnostics.get(scriptUri); + expect(finalDiags).toBeDefined(); + const stillHasImportError = finalDiags!.diagnostics.some( + (d) => + d.message.includes("MissingContract") || + d.message.includes("failed to resolve") + ); + expect(stillHasImportError).toBe(false); + }, `file://${tmpDir}`); + } finally { + try { + fs.rmdirSync(tmpDir, { recursive: true }); + } catch {} + } + }, 15000); + + test("string identifier import: file deleted on disk breaks previously working import", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cdc-test-")); + try { + // Write flow.json + const flowJson = { + emulators: { + default: { + port: 3569, + serviceAccount: "emulator-account", + }, + }, + contracts: { + WorkingContract: "./WorkingContract.cdc", + }, + networks: { + emulator: "127.0.0.1:3569", + }, + accounts: { + "emulator-account": { + address: "f8d6e0586b0a20c7", + key: "c44604c862a3950ae82d56638929720f44875b2637054a1fdcb4e76b01b40881", + }, + }, + deployments: {}, + }; + fs.writeFileSync( + path.join(tmpDir, "flow.json"), + JSON.stringify(flowJson, null, 2) + ); + + // Write the contract file + const contractPath = path.join(tmpDir, "WorkingContract.cdc"); + const contractContent = `access(all) contract WorkingContract { access(all) let value: Int; init() { self.value = 42 } }`; + fs.writeFileSync(contractPath, contractContent); + + // Write a script that imports WorkingContract + const scriptPath = path.join(tmpDir, "script.cdc"); + const scriptContent = `import "WorkingContract"\naccess(all) fun main() { log(WorkingContract.value) }`; + fs.writeFileSync(scriptPath, scriptContent); + + await withConnection(async (connection) => { + const scriptUri = `file://${scriptPath}`; + + // Track diagnostics - keep ALL diagnostics, not just the last + const diagnostics = new Map(); + const allDiagnostics: PublishDiagnosticsParams[] = []; + connection.onNotification( + PublishDiagnosticsNotification.type, + (params) => { + diagnostics.set(params.uri, params); + allDiagnostics.push(params); + } + ); + + // Open the script - should have no errors + await connection.sendNotification( + DidOpenTextDocumentNotification.type, + { + textDocument: TextDocumentItem.create( + scriptUri, + "cadence", + 1, + scriptContent + ), + } + ); + + // Wait for initial diagnostics + await new Promise((resolve) => setTimeout(resolve, 500)); + + const initialDiags = diagnostics.get(scriptUri); + expect(initialDiags).toBeDefined(); + expect(initialDiags!.diagnostics).toHaveLength(0); + + // Give the file watcher time to fully set up after opening the file + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Set up a promise to wait for the next diagnostic after deletion + const nextDiagAfterDeletion = new Promise((resolve) => { + const handler = (params: PublishDiagnosticsParams) => { + if (params.uri === scriptUri) { + resolve(params); + } + }; + connection.onNotification(PublishDiagnosticsNotification.type, handler); + }); + + // Now delete the contract file on disk + fs.unlinkSync(contractPath); + + // Wait for file watcher to detect the deletion and trigger re-check + const diagAfterDeletion = await Promise.race([ + nextDiagAfterDeletion, + new Promise((_, reject) => + setTimeout(() => reject(new Error("Timeout waiting for diagnostic after deletion")), 3000) + ) + ]); + + // Check that the diagnostic has errors + const hasImportError = diagAfterDeletion.diagnostics.some( + (d) => + d.message.includes("WorkingContract") || + d.message.includes("failed to resolve") || + d.message.includes("cannot find") + ); + expect(hasImportError).toBe(true); + }, `file://${tmpDir}`); + } finally { + try { + fs.rmdirSync(tmpDir, { recursive: true }); + } catch {} + } + }, 15000); +}); diff --git a/languageserver/test/multiconfig.test.ts b/languageserver/test/multiconfig.test.ts index 016d8340..c9047822 100644 --- a/languageserver/test/multiconfig.test.ts +++ b/languageserver/test/multiconfig.test.ts @@ -49,7 +49,12 @@ async function withConnection( try { await f(connection); } finally { - await connection.sendNotification(ExitNotification.type); + try { + await connection.sendNotification(ExitNotification.type); + } catch (e) { + // Connection may already be closed + } + child.kill(); } } @@ -245,11 +250,8 @@ describe("multi-config routing (no flow client)", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "tmp-")); const aDir = fs.mkdtempSync(path.join(root, "dir-")); const bDir = fs.mkdtempSync(path.join(root, "dir-")); - writeFlow(aDir, "moose"); - writeFlow(bDir, "elk"); - // flow.json expects contracts mapping; reuse base and override path names - // We'll write files matching those paths + // Write contract files first fs.writeFileSync( path.join(aDir, "fooA.cdc"), "access(all) contract Foo { access(all) let bar: Int; init(){ self.bar = 1 } }" @@ -259,19 +261,33 @@ describe("multi-config routing (no flow client)", () => { "access(all) contract Foo { access(all) let bar: Int; init(){ self.bar = 2 } }" ); - // Overwrite the flow.json contracts mapping to point to the specific files - const aFlow = JSON.parse( - fs.readFileSync(path.join(aDir, FLOW_JSON), "utf8") - ); + // Write flow.json files with correct contracts mapping - only write ONCE + // For aDir, keep account as "moose" + const aFlow = JSON.parse(JSON.stringify(baseFlow)); aFlow.contracts = { Foo: "./fooA.cdc" }; + // Update deployments to only include Foo (remove Bar which doesn't exist) + if (aFlow.deployments && aFlow.deployments.emulator && aFlow.deployments.emulator.moose) { + aFlow.deployments.emulator.moose = ["Foo"]; + } + fs.mkdirSync(aDir, { recursive: true }); fs.writeFileSync( path.join(aDir, FLOW_JSON), JSON.stringify(aFlow, null, 2) ); - const bFlow = JSON.parse( - fs.readFileSync(path.join(bDir, FLOW_JSON), "utf8") - ); + + // For bDir, rename account from "moose" to "elk" + const bFlow = JSON.parse(JSON.stringify(baseFlow)); + if (bFlow.accounts && bFlow.accounts.moose) { + bFlow.accounts["elk"] = bFlow.accounts.moose; + delete bFlow.accounts.moose; + } bFlow.contracts = { Foo: "./fooB.cdc" }; + // Update deployments: rename moose to elk and only deploy Foo + if (bFlow.deployments && bFlow.deployments.emulator && bFlow.deployments.emulator.moose) { + bFlow.deployments.emulator["elk"] = ["Foo"]; + delete bFlow.deployments.emulator.moose; + } + fs.mkdirSync(bDir, { recursive: true }); fs.writeFileSync( path.join(bDir, FLOW_JSON), JSON.stringify(bFlow, null, 2) @@ -281,11 +297,17 @@ describe("multi-config routing (no flow client)", () => { fs.writeFileSync(path.join(aDir, "script.cdc"), script); fs.writeFileSync(path.join(bDir, "script.cdc"), script); + // Give filesystem time to flush writes before starting language server + await new Promise((resolve) => setTimeout(resolve, 200)); + try { await withConnection(async (connection) => { const aUri = `file://${aDir}/script.cdc`; const bUri = `file://${bDir}/script.cdc`; + // Give language server time to initialize and discover flow.json files + await new Promise((resolve) => setTimeout(resolve, 500)); + const got = new Map(); const both = new Promise((resolve) => { connection.onNotification( diff --git a/test/go.mod b/test/go.mod index c878ab23..d94517c2 100644 --- a/test/go.mod +++ b/test/go.mod @@ -6,7 +6,7 @@ require ( github.com/btcsuite/btcd/chaincfg/chainhash v1.0.2 github.com/onflow/atree v0.11.0 github.com/onflow/cadence v1.8.1 - github.com/onflow/flow-emulator v1.9.0 + github.com/onflow/flow-emulator v1.10.1 github.com/onflow/flow-go v0.43.3-0.20251021182938-b0fef2c5ca47 github.com/onflow/flow-go-sdk v1.9.0 github.com/rs/zerolog v1.34.0 @@ -206,9 +206,9 @@ require ( golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect gonum.org/v1/gonum v0.16.0 // indirect google.golang.org/appengine v1.6.8 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect - google.golang.org/grpc v1.75.1 // indirect + google.golang.org/grpc v1.76.0 // indirect google.golang.org/protobuf v1.36.9 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/test/go.sum b/test/go.sum index 93fbf365..8cb4f754 100644 --- a/test/go.sum +++ b/test/go.sum @@ -263,8 +263,8 @@ github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3Bop github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI= -github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA= +github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI= +github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.12.0 h1:e4o3o3IsBfAKQh5Qbbiqyfu97Ku7jrO/JbohvztANh4= github.com/go-kit/kit v0.12.0/go.mod h1:lHd+EkCZPIwYItmGDDRdhinkzX2A1sj+M9biaEaizzs= @@ -597,8 +597,8 @@ github.com/onflow/flow-core-contracts/lib/go/contracts v1.9.1 h1:u6am8NzuWOIKkSk github.com/onflow/flow-core-contracts/lib/go/contracts v1.9.1/go.mod h1:jBDqVep0ICzhXky56YlyO4aiV2Jl/5r7wnqUPpvi7zE= github.com/onflow/flow-core-contracts/lib/go/templates v1.9.1 h1:ebyynXy74ZcfW+JpPwI+aaY0ezlxxA0cUgUrjhJonWg= github.com/onflow/flow-core-contracts/lib/go/templates v1.9.1/go.mod h1:twSVyUt3rNrgzAmxtBX+1Gw64QlPemy17cyvnXYy1Ug= -github.com/onflow/flow-emulator v1.9.0 h1:cAi64Xi7UROU2KNWXV0un009OZy+S6N2j4LCne29LRk= -github.com/onflow/flow-emulator v1.9.0/go.mod h1:Mxo1VjgerVyAbYVPdb6+21Gzng5Ckfufy5gzHgLXX3c= +github.com/onflow/flow-emulator v1.10.1 h1:c/wtpXDI0o+n/icDUzSgCvT/4mT6WYW+nxaeiggmdGY= +github.com/onflow/flow-emulator v1.10.1/go.mod h1:+PbfGuya48rdW80en3msv2CLH8XM+7YEZYFHNIDNpeo= github.com/onflow/flow-evm-bridge v0.1.0 h1:7X2osvo4NnQgHj8aERUmbYtv9FateX8liotoLnPL9nM= github.com/onflow/flow-evm-bridge v0.1.0/go.mod h1:5UYwsnu6WcBNrwitGFxphCl5yq7fbWYGYuiCSTVF6pk= github.com/onflow/flow-ft/lib/go/contracts v1.0.1 h1:Ts5ob+CoCY2EjEd0W6vdLJ7hLL3SsEftzXG2JlmSe24= @@ -1249,8 +1249,8 @@ google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6D google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s= -google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 h1:FiusG7LWj+4byqhbvmB+Q93B/mOxJLN2DTozDuZm4EU= -google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:kXqgZtrWaf6qS3jZOCnCH7WYfrvFjkC51bM8fz3RsCA= +google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b h1:ULiyYQ0FdsJhwwZUwbaXpZF5yUE3h+RA+gxvBu37ucc= +google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:oDOGiMSXHL4sDTJvFvIB9nRQCGdLP1o/iVaqQK8zB+M= google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b h1:zPKJod4w6F1+nRGDI9ubnXYhU9NSWoFAijkHkUXeTK8= google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= @@ -1270,8 +1270,8 @@ google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= -google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= +google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=