Skip to content

Add document links for every tsconfig path - #334894

Open
Andrei L (unrevised6419) wants to merge 5 commits into
microsoft:mainfrom
unrevised6419:tsconfig-document-links
Open

Add document links for every tsconfig path#334894
Andrei L (unrevised6419) wants to merge 5 commits into
microsoft:mainfrom
unrevised6419:tsconfig-document-links

Conversation

@unrevised6419

@unrevised6419 Andrei L (unrevised6419) commented Sep 7, 2026

Copy link
Copy Markdown

Description

TsconfigLinkProvider links three fields today: extends, files and references[].path. Every other value in a project config that names a file or a directory is inert. extends also only works in its string form, so the array form TypeScript 5.0 introduced is silently dropped: the array node is handed to a check that requires a string node, and every entry disappears.

This adds links for the rest. Eighteen contribution rows cover the existing three plus include, exclude, compilerOptions.lib, types, typeRoots, rootDir, rootDirs, baseUrl, outDir, declarationDir, outFile, tsBuildInfoFile, mapRoot and sourceRoot.

Demo

https://youtu.be/L0CBEplaOtc

image

Approach

The provider is table-driven rather than a method per field, because the previous shape meant every new field edited the same three methods. Adding a field is now one row, and adding a resolution strategy is a row plus a function.

languageFeatures/tsconfig/ holds four pieces:

  • links.ts maps JSON paths to link kinds and produces link candidates. It performs no file system access, which is what makes it directly testable. A path segment can be a symbol wildcard matching every array element, so extends is expressed as two rows and both its forms work with no branching.
  • resolvers.ts describes each kind in one record: how it resolves to a target URI, what it says when it cannot, and what to do when the target does not exist. The existing extends and references resolution moves here, with its path-versus-module test aligned with the compiler's, so .\foo, .., \\server\share and C:\foo count as paths on every platform instead of depending on the host's path.isAbsolute. Separators are normalized the way the compiler does before a value reaches a URI, since Uri.file and Uri.joinPath only read a backslash as a separator on Windows, so a path written on Windows resolves the same everywhere.
  • libMap.ts, with an .electron.ts and a .browser.ts implementation, reads a TypeScript install's libMap, which is what turns a lib entry into a file name.
  • index.ts registers the provider and the command, and carries out the outcome: open, reveal, or report.

Behaviors worth calling out:

  • include and exclude entries are usually globs, so only the literal leading segments are underlined and followed. src/**/* links src. An entry that begins with a wildcard has no directory to point at and gets no link, and neither does ./*.ts, whose only literal segment is the config's own directory.
  • Directory targets reveal in the explorer, since vscode.open cannot open a folder in an editor. This applies to typeRoots, rootDir, baseUrl and glob prefixes. A directory outside the workspace cannot be selected in the explorer, so it is revealed in the OS file manager in a local desktop window and reported with a message elsewhere, remote windows included, where that command has no local path to show.
  • lib entries resolve against the TypeScript the language service is actually using, honoring typescript.tsdk and the workspace version picker, then any other local install, then the bundled copy. The file comes from the install rather than from the entry's spelling: a name the install ships a lib.<name>.d.ts for resolves to that file, and every other name, which is to say the aliases, goes through the install's own libMap, so ES7 and ESNext.BigInt open lib.es2016.d.ts and lib.es2020.bigint.d.ts exactly as the compiler would, and a name neither knows is reported. An install the service could not read is skipped, the same fallback the version manager makes. On desktop the map is read from the typescript.js beside the install's tsserver.js, which is why it is consulted only for the aliases: loading it is expensive, so the result is cached against that file's modification time and the module is dropped again once the map is taken from it. An install inside the workspace is only loaded once the workspace is trusted, the same rule tsserver follows. On web the bundle step writes libMap.json next to the lib.*.d.ts files it already copies, from the same TypeScript, and the lib opens as a readonly text document rather than being handed to the browser.
  • types entries that are paths rather than package names, such as ./typings/foo, resolve as paths.
  • A missing target still opens, and so still offers to create the file, for the kinds that name files. The kinds that name directories report instead, so a missing "rootDir": "./src" no longer offers to create a file called src.
  • mapRoot and sourceRoot accept a URL as well as a path, so a value with a URI scheme gets no link.

TypeScriptVersionManager decided which install is active inside its constructor. That rule moves into an exported function beside it, so the lib resolver can ask the same question instead of reading the manager's storage key and reimplementing the rule. The version provider is configured at activation from the two tsdk settings alone, and reconfigured when they change, so lib links work with the native preview enabled, where no service client ever configures it. The full configuration load is not used for this because it shells out synchronously when tsserver.nodePath is set to node, and that must not sit on the activation path.

Deliberately left out

Each is a row plus at most a resolver once this lands:

  • compilerOptions.paths values, which resolve against baseUrl and so need document context the resolver contract does not carry. They would also need an object wildcard, since paths keys are globs.
  • plugins[].name and jsxImportSource, which are package resolution rather than path resolution.
  • watchOptions.excludeFiles and excludeDirectories.
  • The deprecated out.

Three known limitations are recorded in comments: types entries are not resolved against typeRoots, the @typescript/lib-* override introduced in TypeScript 5.0 is not honored, and a link followed from its hover rather than from the document has its command arguments decoded once more than a ctrl+click does, so a value carrying a literal % resolves differently between the two gestures.

Supersedes

This replaces two of my open pull requests, which I am closing in favor of it:

It also touches the same seam as #318274, which makes the document selector configurable. That one is unaffected in substance: rebased onto this, it replaces only the selector-building step in index.ts and stops touching link logic. tsconfig.ts becomes tsconfig/index.ts here, so whichever lands second needs a rebase.

How to test

  1. Open a workspace with a tsconfig.json such as:
    {
      "extends": ["./tsconfig.base.json"],
      "files": ["src/main.ts"],
      "include": ["src/**/*"],
      "compilerOptions": {
        "lib": ["DOM", "ES2022"],
        "types": ["node"],
        "typeRoots": ["./typings"],
        "rootDir": "./src",
        "outDir": "./out"
      }
    }
    with tsconfig.base.json, src/, typings/ and node_modules/@types/node present, and no out/.
  2. Ctrl+Click (Cmd+Click) each value. Files open, directories reveal in the explorer, DOM opens lib.dom.d.ts, and node opens the @types/node declaration.
    Add "ES7" and "ESNext.BigInt" to lib and confirm they open lib.es2016.d.ts and lib.es2020.bigint.d.ts; add "NotALib" and confirm it reports that the lib could not be resolved.
  3. In "include": ["src/**/*"], confirm only src is underlined and that following it reveals the folder.
  4. Ctrl+Click outDir before building and confirm it reports that the path does not exist yet.
  5. Switch between the bundled and workspace TypeScript with TypeScript: Select TypeScript Version, then follow a lib entry again and confirm the target follows the selection.
  6. Confirm the single-string form of extends still works, and that files and references are unaffected.
  7. Point outDir at an existing directory outside the workspace and confirm following it reveals the folder in the OS file manager.
  8. On web (./scripts/code-web.sh <folder>, after npm run bundle-web in the extension), follow DOM and confirm it opens as a readonly editor rather than a browser tab.

Unit tests cover the selection layer, the path classification, the lib map reading and the command handler's branches: ./scripts/test-integration.sh --suite typescript.

🤖 Generated with Claude Code

https://claude.ai/code/session_014hRTv3iMuJ7GVzFybCs5j7
https://claude.ai/code/session_01WadC2xvsDdvGEyyV4ia2B8

`TsconfigLinkProvider` linked three fields: `extends`, `files`, and
`references[].path`. Every other value in a project config that names a
file or a directory was inert, and `extends` only worked in its string
form, so the array form TypeScript 5.0 introduced was silently dropped.

Eighteen contribution rows now cover those three plus `include`,
`exclude`, `compilerOptions.lib`, `types`, `typeRoots`, `rootDir`,
`rootDirs`, `baseUrl`, `outDir`, `declarationDir`, `outFile`,
`tsBuildInfoFile`, `mapRoot`, and `sourceRoot`.

The provider is table-driven rather than a method per field, so a future
field is one row and a future resolution strategy is a row plus a
function. `links.ts` maps JSON paths to link kinds and produces
candidates without touching the file system, which makes it directly
testable. `resolvers.ts` turns a kind and a string into a target URI.
`index.ts` registers the provider and opens what a resolver returns.

Notable behaviors:

- `include` and `exclude` entries are usually globs, so only the literal
  leading segments are underlined and followed: `src/**/*` links `src`.
- Directory targets reveal in the explorer, since an editor cannot open
  a folder.
- `lib` entries resolve against the TypeScript the language service is
  actually using, honoring `typescript.tsdk` and the workspace version
  picker, falling back to the bundled copy.
- A missing target still offers to create the file for the kinds that
  name files, and reports for the kinds that name directories.

`TypeScriptVersionManager` decided which install is active inside its
constructor. That rule moves to an exported function so the `lib`
resolver can ask the same question, rather than reading the manager's
storage key and reimplementing it. The version provider is configured on
first use rather than at activation, because loading the configuration
can shell out synchronously.

Left for later, each one a row plus at most a resolver:
`compilerOptions.paths` values, which resolve against `baseUrl` and need
document context the resolver contract does not carry; `plugins[].name`
and `jsxImportSource`, which are package resolution; `watchOptions`; and
the deprecated `out`.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Note

This error may be related to your runner configuration. You can now configure runners for Copilot code review separately from Copilot cloud agent by creating a copilot-code-review.yml file with your setup steps. Read the docs for details.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Note

This error may be related to your runner configuration. You can now configure runners for Copilot code review separately from Copilot cloud agent by creating a copilot-code-review.yml file with your setup steps. Read the docs for details.

@unrevised6419
Andrei L (unrevised6419) marked this pull request as draft September 7, 2026 11:19
@unrevised6419

Andrei L (unrevised6419) commented Sep 7, 2026

Copy link
Copy Markdown
Author

Initial review notes, ordered by impact. Line references are against the current head of the branch.

Bugs

  1. lib aliases fail to resolve (resolvers.ts:123). resolveLibPath maps the value directly to lib.<name>.d.ts, but TypeScript resolves lib names through its libEntries alias table (libMap). Verified against the bundled TypeScript 6.0.3: "lib": ["ES7", "ESNext.BigInt"] parses with zero errors and resolves to lib.es2016.d.ts / lib.es2020.bigint.d.ts, yet neither lib.es7.d.ts nor lib.esnext.bigint.d.ts exists on disk, so both links show "Failed to resolve TypeScript lib". Eleven aliases have no file of their own in 6.0.3: es7, esnext.asynciterable, esnext.symbol, esnext.bigint, esnext.weakref, esnext.object, esnext.regexp, esnext.string, esnext.float16, esnext.iterator, esnext.promise. The set shifts every TypeScript release as esnext.* entries are retargeted, so the resolver needs the alias table for the version it is resolving against, or at least a static copy with a fallback. Fixed in 58afc89.

  2. Regression: files entries lose Alt+click open-to-side (index.ts:117). The old provider emitted plain file: targets for files, which EditorOpener.open forwards with openToSide. The new command: links go through CommandOpener, which never passes the modifier, and the handler calls vscode.open with no view column. Alt+click on "files": ["src/main.ts"] now opens in the active group. Keeping plain resource URIs for ProjectFile restores the previous behavior. Won't fix: every other kind already goes through the command link, so files behaving the same keeps one link path, and open-to-side on a config entry is a niche gesture.

  3. selectNonGlobPrefix only knows / (links.ts:76). "./*.ts" yields lastIndexOf('/', 2) === 1, so the link covers a single . character and resolves to the tsconfig's own folder, triggering revealInExplorer on it. Backslash-authored patterns such as "src\\**\\*" (valid, TypeScript normalizes them) get no link at all. Fixed in 1274943.

  4. resolveTypePackage treats Windows absolute paths as package names (resolvers.ts:182). The prefix list ['./', '../', '/'] misses C:\x, C:/x, \\x, .\foo, and bare .., all of which TypeScript's isExternalModuleNameRelative accepts. "types": ["C:\\typings\\foo"] becomes @types/C:\typings\foo and fails. looksLikeAbsoluteWindowsPath is already imported in this file for getTsconfigPath, and resolveRelativePath uses platform-dependent path.isAbsolute, so three different absolute checks coexist. One shared helper would fix all three. Fixed in 1274943.

  5. Web: lib links never fail and open a browser tab (resolvers.ts:158). On vscode.dev StaticVersionProvider sets TypeScriptVersion.path to an https://.../tsserver.web.js URL, and FetchFileSystemProvider.stat returns FileType.File unconditionally, so exists() is always true and "lib": ["Foo"] never reports failure. Following the link runs vscode.open(httpsUri), which openerService treats as external and opens a raw .d.ts (or a 404) in a new tab. Fixed in 58afc89.

  6. tsgo mode resolves lib against a stale typescript.tsdk (extension.ts:52). configuredVersionProvider is a Lazy that calls updateConfiguration once and never listens to onDidChangeConfiguration. In the tsgo branch no TypeScriptServiceClient is constructed, so nothing else refreshes it. Change typescript.tsdk after the first click and the old install's lib.dom.d.ts keeps opening until reload. Fixed in 24d952f.

  7. revealInExplorer is silent for directories outside the workspace (index.ts:113). Every directory stat routes to revealInExplorer, but the workbench handler only selects when isInsideWorkspace(uri); otherwise it just focuses Open Editors. "outDir": "../dist", "typeRoots": ["../../shared/typings"], or an @types package found above the workspace root: the click appears to do nothing, no message. In an empty-workspace window every directory target hits this path. Fixed in 1274943.

  8. bundledVersion toasts twice per click (resolvers.ts:133). The DiskTypeScriptVersionProvider.bundledVersion getter shows an error toast before throwing and is not memoized. With no global tsdk it is reached once via getActiveTypeScriptVersion -> defaultVersion and once via the explicit push, so a broken install yields two identical toasts plus the "Failed to resolve" message. Separately, localVersion and localVersions each rescan disk synchronously per workspace folder on every click. Fixed in 1274943.

Quality

  1. First lib click runs the full loadFromWorkspace() (extension.ts:53), which can synchronously spawn node (2s timeout) via findNodePath on the extension host thread and show Node-related warning toasts unrelated to the link. DiskTypeScriptVersionProvider only consumes globalTsdk / localTsdk from that result. In normal mode this duplicates the load and toasts the service client already produced at construction. Fixed in 24d952f.

  2. TsConfigLinkKind overloads resolution strategy, error wording, and missing-target policy (index.ts:79). resolveRelativePath is typed Promise<vscode.Uri> and serves ProjectFile, Path, BuildOutput, so the !target check and the "Failed to resolve {0}" arm of getResolveErrorMessage are dead for those kinds. The default: arm at line 157 is what gives Lib and TypePackage the "does not exist" wording, which contradicts the "deliberately exhaustive" intent of the sibling switch. A per-kind descriptor { resolve, unresolvedMessage, missingTargetPolicy } would remove both switches. Fixed in 24d952f.

🤖 Generated with Claude Code

- A glob whose only literal prefix is `.` (`./*.ts`) produced a one-character
  link that revealed the folder the config already lives in. It is no longer
  linked.
- `types` entries and the other path resolvers now classify relative and
  absolute paths the way the compiler does, on every platform: `.`, `..`,
  `.\foo`, `\\server\share` and `C:\foo` are paths, not package names, and
  the check no longer depends on `path.isAbsolute` of the host platform.
- `lib` resolution read `bundledVersion` twice per click. The getter rescans
  disk and shows an error toast before throwing, so a missing bundled install
  produced two identical toasts. It is now read once.
- A directory target outside the workspace was handed to `revealInExplorer`,
  which silently does nothing for it. It is now revealed in the OS file
  manager on desktop, or reported with a message elsewhere.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WadC2xvsDdvGEyyV4ia2B8
…able

The version provider used by `lib` links was configured lazily on the first
click through the full `loadFromWorkspace()`, which reads every TypeScript
setting and, when `tsserver.nodePath` is "node", synchronously spawns `node`
and may show Node warnings unrelated to the link. It also ran only once, so
with the native preview enabled a later change to `typescript.tsdk` was
ignored until reload.

`loadTsdkFromWorkspace()` now reads just the two tsdk settings, which is all
the provider consumes, and is cheap enough to run at activation and again
whenever those settings change.

Each link kind's resolver, failure wording, and missing-target policy were
spread over a resolver record and two switches. They now live in one
descriptor record, so adding a kind is one entry rather than three places,
and no kind inherits wording from a `default` arm.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WadC2xvsDdvGEyyV4ia2B8
…em on web

A `lib` entry was mapped straight to `lib.<name>.d.ts`, but TypeScript
resolves it through its `libMap`, where aliases such as `ES7` or
`ESNext.BigInt` point at the file of the edition that shipped the feature.
Those entries compiled fine yet reported "Failed to resolve TypeScript lib".

The resolver now consults the lib map of each candidate install and nothing
else, so an entry the map does not know is not a lib, however plausible the
file name would look. On desktop the map is read from the `typescript.js`
beside the install's `tsserver.js`, cached per install, and an install inside
the workspace is only loaded once the workspace is trusted, the same rule
tsserver follows. On web the bundle step writes `libMap.json` next to the lib
files it already copies, from the same TypeScript, so the two never drift.

On web the lib files are served over http(s), where the workbench's fetch
provider answers every `stat` with a file and `vscode.open` hands the URL to
the browser, which offered to download it. Existence is now checked with a
real read, and http(s) targets open as text documents.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WadC2xvsDdvGEyyV4ia2B8
Link ranges are now mapped through the source text a value was decoded from,
so a narrowed link inside an escaped value points at the characters it names
instead of being dropped, and a string whose closing quote has not been typed
yet is no longer underlined one character short. Glob prefixes accept either
separator, the way the compiler normalizes them.

A path written with Windows separators now resolves the same on every
platform: values pass through `normalizeSlashes` before reaching a URI, since
`Uri.file` and `Uri.joinPath` only treat a backslash as a separator on
Windows. `looksLikeRelativePath` and `looksLikeAbsolutePath` move beside the
other path predicates in `utils/fs`, along with a shared `tryStat`.

A `lib` entry now resolves through the file the install ships whenever one is
named for it, which is all but the aliases, so the common case no longer loads
the install's compiler on the extension host thread. Only a value shaped like
a lib name is looked up that way, so a value carrying a separator cannot point
the link out of the install. The map read that the aliases still need caches
failures too, keys the cache by the modification time of the `typescript.js`
it read, so an install replaced in place is picked up, and drops the module
again once the map is taken from it. An install the service could not read is
skipped, matching the fallback `TypeScriptVersionManager.reset` performs.

On web, a lib file is no longer downloaded once to prove it exists and again
to show it: the lib map already says the file ships with that install. A
folder outside the workspace reports rather than calling `revealFileInOS` in a
remote window, where that command does nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6zKNmBMmzGbtjX3C4pWkj
@unrevised6419
Andrei L (unrevised6419) marked this pull request as ready for review September 7, 2026 14:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants