Skip to content

Commit

Permalink
feat(typescript): support document links for tsconfig
Browse files Browse the repository at this point in the history
  • Loading branch information
johnsoncodehk committed Apr 16, 2023
1 parent bed0a68 commit b893af9
Show file tree
Hide file tree
Showing 4 changed files with 244 additions and 7 deletions.
7 changes: 2 additions & 5 deletions packages/typescript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
"@types/semver": "^7.3.13"
},
"dependencies": {
"jsonc-parser": "^3.2.0",
"minimatch": "^9.0.0",
"semver": "^7.3.8",
"vscode-languageserver-protocol": "^3.17.3",
"vscode-languageserver-textdocument": "^1.0.8",
Expand All @@ -25,10 +27,5 @@
},
"peerDependencies": {
"@volar/language-service": "*"
},
"peerDependenciesMeta": {
"@volar/language-service": {
"optional": true
}
}
}
14 changes: 14 additions & 0 deletions packages/typescript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import * as semanticTokens from './services/semanticTokens';
import * as signatureHelp from './services/signatureHelp';
import * as typeDefinitions from './services/typeDefinition';
import * as workspaceSymbols from './services/workspaceSymbol';
import * as tsconfig from './services/tsconfig';
import { SharedContext } from './types';

export default (): LanguageServicePlugin => (contextOrNull): LanguageServicePluginInstance => {
Expand Down Expand Up @@ -94,6 +95,7 @@ export default (): LanguageServicePlugin => (contextOrNull): LanguageServicePlug
const doValidation = diagnostics.register(semanticCtx);
const getDocumentSemanticTokens = semanticTokens.register(semanticCtx);
const callHierarchy = _callHierarchy.register(semanticCtx);
const tsconfigRequests = tsconfig.register(semanticCtx);

let syntacticHostCtx = {
fileName: '',
Expand Down Expand Up @@ -418,6 +420,18 @@ export default (): LanguageServicePlugin => (contextOrNull): LanguageServicePlug
}
}
},

/**
* for tsconfig: https://github.com/microsoft/vscode/blob/main/extensions/typescript-language-features/src/languageFeatures/tsconfig.ts
*/

provideDocumentLinks(document, token) {
return tsconfigRequests.provideDocumentLinks(document, token);
},

resolveDocumentLink(link, token) {
return tsconfigRequests.resolve(link, token);
},
};

function prepareSyntacticService(document: TextDocument) {
Expand Down
222 changes: 222 additions & 0 deletions packages/typescript/src/services/tsconfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import * as jsonc from 'jsonc-parser';
import { minimatch } from 'minimatch';
import * as vscode from '@volar/language-service';
import { URI, Utils } from 'vscode-uri';
import { SharedContext } from '../types';

interface OpenExtendsLinkCommandArgs {
resourceUri: string;
extendsValue: string;
}

function mapChildren<R>(node: jsonc.Node | undefined, f: (x: jsonc.Node) => R): R[] {
return node && node.type === 'array' && node.children
? node.children.map(f)
: [];
}

export function register(ctx: SharedContext) {

const patterns = [
'**/[jt]sconfig.json',
'**/[jt]sconfig.*.json',
];
const languages = ['json', 'jsonc'];

return {
provideDocumentLinks,
async resolve(link: vscode.DocumentLink, _token: vscode.CancellationToken) {
const data: OpenExtendsLinkCommandArgs = link.data;
if (data) {
const tsconfigPath = await getTsconfigPath(Utils.dirname(URI.parse(data.resourceUri)), data.extendsValue);
if (tsconfigPath === undefined) {
// console.error(vscode.l10n.t("Failed to resolve {0} as module", data.extendsValue));
}
link.target = tsconfigPath?.toString();
}
return link;
},
};


function provideDocumentLinks(
document: vscode.TextDocument,
_token: vscode.CancellationToken
): vscode.DocumentLink[] {

const match = languages.includes(document.languageId) && patterns.some(pattern => minimatch(document.uri, pattern));
if (!match) {
return [];
}

const root = jsonc.parseTree(document.getText());
if (!root) {
return [];
}

const links = [
getExtendsLink(document, root),
...getFilesLinks(document, root),
...getReferencesLinks(document, root)
];
return links.filter(link => !!link) as vscode.DocumentLink[];
}

function getExtendsLink(document: vscode.TextDocument, root: jsonc.Node): vscode.DocumentLink | undefined {
const extendsNode = jsonc.findNodeAtLocation(root, ['extends']);
if (!isPathValue(extendsNode)) {
return undefined;
}

const extendsValue: string = extendsNode.value;
if (extendsValue.startsWith('/')) {
return undefined;
}

const args: OpenExtendsLinkCommandArgs = {
resourceUri: document.uri,
extendsValue: extendsValue
};

const link = vscode.DocumentLink.create(
getRange(document, extendsNode),
undefined,
args
);
// link.tooltip = vscode.l10n.t("Follow link");
link.tooltip = "Follow link";
return link;
}

function getFilesLinks(document: vscode.TextDocument, root: jsonc.Node) {
return mapChildren(
jsonc.findNodeAtLocation(root, ['files']),
child => pathNodeToLink(document, child));
}

function getReferencesLinks(document: vscode.TextDocument, root: jsonc.Node) {
return mapChildren(
jsonc.findNodeAtLocation(root, ['references']),
child => {
const pathNode = jsonc.findNodeAtLocation(child, ['path']);
if (!isPathValue(pathNode)) {
return undefined;
}

return vscode.DocumentLink.create(
getRange(document, pathNode),
pathNode.value.endsWith('.json')
? getFileTarget(document, pathNode)
: getFolderTarget(document, pathNode)
);
});
}

function pathNodeToLink(
document: vscode.TextDocument,
node: jsonc.Node | undefined
): vscode.DocumentLink | undefined {
return isPathValue(node)
? vscode.DocumentLink.create(getRange(document, node), getFileTarget(document, node))
: undefined;
}

function isPathValue(extendsNode: jsonc.Node | undefined): extendsNode is jsonc.Node {
return extendsNode
&& extendsNode.type === 'string'
&& extendsNode.value
&& !(extendsNode.value as string).includes('*'); // don't treat globs as links.
}

function getFileTarget(document: vscode.TextDocument, node: jsonc.Node): string {
return Utils.joinPath(Utils.dirname(URI.parse(document.uri)), node.value).toString();
}

function getFolderTarget(document: vscode.TextDocument, node: jsonc.Node): string {
return Utils.joinPath(Utils.dirname(URI.parse(document.uri)), node.value, 'tsconfig.json').toString();
}

function getRange(document: vscode.TextDocument, node: jsonc.Node) {
const offset = node.offset;
const start = document.positionAt(offset + 1);
const end = document.positionAt(offset + (node.length - 1));
return vscode.Range.create(start, end);
}

async function resolveNodeModulesPath(baseDirUri: URI, pathCandidates: string[]): Promise<URI | undefined> {
let currentUri = baseDirUri;
const baseCandidate = pathCandidates[0];
const sepIndex = baseCandidate.startsWith('@') ? 2 : 1;
const moduleBasePath = baseCandidate.split('/').slice(0, sepIndex).join('/');
while (true) {
const moduleAbsoluteUrl = Utils.joinPath(currentUri, 'node_modules', moduleBasePath);
let moduleStat: Awaited<ReturnType<NonNullable<typeof ctx.fileSystemProvider>['stat']>> | undefined;
try {
moduleStat = await ctx.fileSystemProvider?.stat(moduleAbsoluteUrl.toString());
} catch (err) {
// noop
}

if (moduleStat && moduleStat.type === 2 /* Directory */) {
for (const uriCandidate of pathCandidates
.map((relativePath) => relativePath.split('/').slice(sepIndex).join('/'))
// skip empty paths within module
.filter(Boolean)
.map((relativeModulePath) => Utils.joinPath(moduleAbsoluteUrl, relativeModulePath))
) {
if (await exists(uriCandidate)) {
return uriCandidate;
}
}
// Continue to looking for potentially another version
}

const oldUri = currentUri;
currentUri = Utils.joinPath(currentUri, '..');

// Can't go next. Reached the system root
if (oldUri.path === currentUri.path) {
return;
}
}
}

// Reference: https://github.com/microsoft/TypeScript/blob/febfd442cdba343771f478cf433b0892f213ad2f/src/compiler/commandLineParser.ts#L3005
/**
* @returns Returns undefined in case of lack of result while trying to resolve from node_modules
*/
async function getTsconfigPath(baseDirUri: URI, extendsValue: string): Promise<URI | undefined> {
// Don't take into account a case, where tsconfig might be resolved from the root (see the reference)
// e.g. C:/projects/shared-tsconfig/tsconfig.json (note that C: prefix is optional)

const isRelativePath = ['./', '../'].some(str => extendsValue.startsWith(str));
if (isRelativePath) {
const absolutePath = Utils.joinPath(baseDirUri, extendsValue);
if (await exists(absolutePath) || absolutePath.path.endsWith('.json')) {
return absolutePath;
}
return absolutePath.with({
path: `${absolutePath.path}.json`
});
}

// Otherwise resolve like a module
return resolveNodeModulesPath(baseDirUri, [
extendsValue,
...extendsValue.endsWith('.json') ? [] : [
`${extendsValue}.json`,
`${extendsValue}/tsconfig.json`,
]
]);
}

async function exists(resource: URI): Promise<boolean> {
try {
const stat = await ctx.fileSystemProvider?.stat(resource.toString());
// stat.type is an enum flag
return !!(stat?.type === 1);
} catch {
return false;
}
}
}
8 changes: 6 additions & 2 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit b893af9

Please sign in to comment.