Prop completion from packages / component libraries (#24)

* Prop autocompletion from astro re-exported files

* Don't use ts-morph to find props

* Add a changeset
This commit is contained in:
Matthew Phillips
2021-09-01 11:09:33 -07:00
committed by GitHub
parent d7ab65361e
commit 72d3ff00c4
10 changed files with 174 additions and 93 deletions

8
.changeset/README.md Normal file
View File

@@ -0,0 +1,8 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)

10
.changeset/config.json Normal file
View File

@@ -0,0 +1,10 @@
{
"$schema": "https://unpkg.com/@changesets/config@1.6.0/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"linked": [["astro-vscode", "@astrojs/language-server"]],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}

View File

@@ -0,0 +1,5 @@
---
"@astrojs/language-server": minor
---
Adds support for prop completion from ts/jsx files

View File

@@ -22,6 +22,4 @@ interface Astro {
site: URL;
}
declare const Astro: Astro;
export default function (): string;
declare const Astro: Astro;

View File

@@ -21,6 +21,7 @@
},
"dependencies": {
"source-map": "^0.7.3",
"ts-morph": "^12.0.0",
"typescript": "^4.3.1-rc",
"vscode-css-languageservice": "^5.1.1",
"vscode-emmet-helper": "2.1.2",

View File

@@ -19,9 +19,10 @@ import {
} from 'vscode-languageserver';
import { Node } from 'vscode-html-languageservice';
import { isPossibleClientComponent, pathToUrl, urlToPath } from '../../utils';
import { toVirtualAstroFilePath } from '../typescript/utils';
import { isAstroFilePath, isVirtualAstroFilePath, toVirtualAstroFilePath } from '../typescript/utils';
import { isInsideFrontmatter } from '../../core/documents/utils';
import * as ts from 'typescript';
import type { FunctionDeclaration } from 'typescript';
import { LanguageServiceManager as TypeScriptLanguageServiceManager } from '../typescript/LanguageServiceManager';
import { ensureRealFilePath } from '../typescript/utils';
import { FoldingRangeKind } from 'vscode-languageserver-types';
@@ -180,6 +181,10 @@ export class AstroPlugin implements CompletionsProvider, FoldingRangeProvider {
return [];
}
if(completionContext?.triggerCharacter === '/' || completionContext?.triggerCharacter === '>') {
return [];
}
// If inside of attributes, skip.
if (completionContext && completionContext.triggerKind === CompletionTriggerKind.TriggerCharacter && completionContext.triggerCharacter === '"') {
return [];
@@ -188,86 +193,108 @@ export class AstroPlugin implements CompletionsProvider, FoldingRangeProvider {
const componentName = node.tag!;
const { lang: thisLang } = await this.tsLanguageServiceManager.getTypeScriptDoc(document);
const defs = this.getDefinitionsForComponentName(document, thisLang, componentName);
// Get the source file
const filePath = urlToPath(document.uri);
const tsFilePath = toVirtualAstroFilePath(filePath!);
if (!defs || !defs.length) {
return [];
}
const defFilePath = ensureRealFilePath(defs[0].fileName);
const lang = await this.tsLanguageServiceManager.getTypeScriptLangForPath(defFilePath);
const program = lang.getProgram();
const sourceFile = program?.getSourceFile(toVirtualAstroFilePath(defFilePath));
const program = thisLang.getProgram();
const sourceFile = program?.getSourceFile(tsFilePath);
const typeChecker = program?.getTypeChecker();
if (!sourceFile || !typeChecker) {
return [];
}
let propsNode = this.getPropsNode(sourceFile);
if (!propsNode) {
// Get the import statement
const imp = this.getImportedSymbol(sourceFile, componentName);
const importType = imp && typeChecker.getTypeAtLocation(imp);
if(!importType) {
return [];
}
// Get the import's type
const componentType = this.getPropType(importType, typeChecker);
if(!componentType) {
return [];
}
const completionItems: CompletionItem[] = [];
for (let type of typeChecker.getBaseTypes(propsNode as unknown as ts.InterfaceType)) {
type.symbol.members!.forEach((mem) => {
let item: CompletionItem = {
label: mem.name,
insertText: mem.name,
commitCharacters: [],
};
mem.getDocumentationComment(typeChecker);
let description = mem
.getDocumentationComment(typeChecker)
.map((val) => val.text)
.join('\n');
if (description) {
let docs: MarkupContent = {
kind: MarkupKind.Markdown,
value: description,
};
item.documentation = docs;
}
completionItems.push(item);
// Add completions for this types props
for(let baseType of componentType.getBaseTypes() || []) {
const members = baseType.getSymbol()?.members || [];
members.forEach(mem => {
let completionItem = this.getCompletionItemForTypeMember(mem, typeChecker);
completionItems.push(completionItem);
});
}
for (let member of propsNode.members) {
if (!member.name) continue;
let name = member.name.getText();
let symbol = typeChecker.getSymbolAtLocation(member.name);
if (!symbol) continue;
let description = symbol
.getDocumentationComment(typeChecker)
.map((val) => val.text)
.join('\n');
let item: CompletionItem = {
label: name,
insertText: name,
commitCharacters: [],
};
if (description) {
let docs: MarkupContent = {
kind: MarkupKind.Markdown,
value: description,
};
item.documentation = docs;
}
completionItems.push(item);
}
// Add completions for this types base members
const members = componentType.getSymbol()?.members || [];
members.forEach(mem => {
let completionItem = this.getCompletionItemForTypeMember(mem, typeChecker);
completionItems.push(completionItem);
});
return completionItems;
}
private getPropType(type: ts.Type, typeChecker: ts.TypeChecker): ts.Type | null {
const sym = type?.getSymbol();
if(!sym) {
return null;
}
for(const decl of sym?.getDeclarations() || []) {
const fileName = decl.getSourceFile().fileName;
if(isVirtualAstroFilePath(fileName)) {
if(!ts.isFunctionDeclaration(decl)) {
console.error(`Unexpected: .astro files should export a default function for the component definition.`);
continue;
}
const fn = decl as FunctionDeclaration;
if(!fn.parameters.length) continue;
const param1 = fn.parameters[0];
const type = typeChecker.getTypeAtLocation(param1);
return type;
} else if(fileName.endsWith('.tsx') || fileName.endsWith('.jsx')) {
if(!ts.isFunctionDeclaration(decl)) {
console.error(`We only support function components for tsx/jsx at the moment.`);
continue;
}
const fn = decl as FunctionDeclaration;
if(!fn.parameters.length) continue;
const param1 = fn.parameters[0];
const type = typeChecker.getTypeAtLocation(param1);
return type;
}
}
return null;
}
private getCompletionItemForTypeMember(mem: ts.Symbol, typeChecker: ts.TypeChecker) {
let item: CompletionItem = {
label: mem.name,
insertText: mem.name,
commitCharacters: [],
};
mem.getDocumentationComment(typeChecker);
let description = mem
.getDocumentationComment(typeChecker)
.map((val) => val.text)
.join('\n');
if (description) {
let docs: MarkupContent = {
kind: MarkupKind.Markdown,
value: description,
};
item.documentation = docs;
}
return item;
}
private isInsideFrontmatter(document: Document, position: Position) {
return isInsideFrontmatter(document.getText(), document.offsetAt(position));
}
@@ -284,7 +311,8 @@ export class AstroPlugin implements CompletionsProvider, FoldingRangeProvider {
const filePath = urlToPath(document.uri);
const tsFilePath = toVirtualAstroFilePath(filePath!);
const sourceFile = lang.getProgram()?.getSourceFile(tsFilePath);
const program = lang.getProgram();
const sourceFile = program?.getSourceFile(tsFilePath);
if (!sourceFile) {
return undefined;
}
@@ -302,36 +330,55 @@ export class AstroPlugin implements CompletionsProvider, FoldingRangeProvider {
return defs;
}
private getImportedSymbol(sourceFile: ts.SourceFile, identifier: string): ts.ImportSpecifier | ts.Identifier | null {
for(let list of sourceFile.getChildren()) {
for(let node of list.getChildren()) {
if (ts.isImportDeclaration(node)) {
let clauses = node.importClause;
if(!clauses) return null;
let namedImport = clauses.getChildAt(0);
if(ts.isNamedImports(namedImport)) {
for (let imp of namedImport.elements) { // Iterate the named imports
if(imp.name.getText() === identifier) {
return imp;
}
}
} else if(ts.isIdentifier(namedImport)) {
if(namedImport.getText() === identifier) {
return namedImport;
}
}
}
}
}
return null;
}
private getImportSpecifierForIdentifier(sourceFile: ts.SourceFile, identifier: string): ts.Expression | undefined {
let importSpecifier: ts.Expression | undefined = undefined;
ts.forEachChild(sourceFile, (tsNode) => {
if (ts.isImportDeclaration(tsNode)) {
if (tsNode.importClause) {
const { name } = tsNode.importClause;
const { name, namedBindings } = tsNode.importClause;
if (name && name.getText() === identifier) {
importSpecifier = tsNode.moduleSpecifier;
return true;
} else if(namedBindings && namedBindings.kind === ts.SyntaxKind.NamedImports) {
const elements = (namedBindings as ts.NamedImports).elements;
for(let elem of elements) {
if(elem.name.getText() === identifier) {
importSpecifier = tsNode.moduleSpecifier;
return true;
}
}
}
}
}
});
return importSpecifier;
}
private getPropsNode(sourceFile: ts.SourceFile): ts.InterfaceDeclaration | null {
let found: ts.InterfaceDeclaration | null = null;
ts.forEachChild(sourceFile, (node) => {
if (isNodeExported(node)) {
if (ts.isInterfaceDeclaration(node)) {
if (ts.getNameOfDeclaration(node)?.getText() === 'Props') {
found = node;
}
}
}
});
return found;
}
}
function isNodeExported(node: ts.Node): boolean {

View File

@@ -75,13 +75,24 @@ class AstroDocumentSnapshot implements DocumentSnapshot {
/** @internal */
private transformContent(content: string) {
let raw = content.replace(/---/g, '///');
return (
content.replace(/---/g, '///') +
raw +
// Add TypeScript definitions
ASTRO_DEFINITION
this.addProps(raw, ASTRO_DEFINITION.toString('utf-8'))
);
}
private addProps(content: string, dtsContent: string): string {
let defaultExportType = 'Record<string, any>';
// Using TypeScript to parse here would cause a double-parse, slowing down the extension
// This needs to be done a different way when the new compiler is added.
if(/(interface|type) Props/.test(content)) {
defaultExportType = 'Props';
}
return dtsContent + '\n' + `export default function (props: ${defaultExportType}): string;`
}
get filePath() {
return this.doc.getFilePath() || '';
}

View File

@@ -57,8 +57,6 @@ export class SnapshotManager {
}
set(fileName: string, snapshot: DocumentSnapshot) {
// const prev = this.get(fileName);
this.logStatistics();
return this.documents.set(fileName, snapshot);
}

View File

@@ -9,7 +9,8 @@ export function createAstroSys(getSnapshot: (fileName: string) => DocumentSnapsh
const AstroSys: ts.System = {
...ts.sys,
fileExists(path: string) {
return ts.sys.fileExists(ensureRealAstroFilePath(path));
let doesExist = ts.sys.fileExists(ensureRealAstroFilePath(path));
return doesExist;
},
readFile(path: string) {
if (isAstroFilePath(path) || isVirtualAstroFilePath(path)) {

View File

@@ -169,12 +169,14 @@ function getDefaultJsConfig(): {
compilerOptions: ts.CompilerOptions;
include: string[];
} {
let compilerOptions = {
maxNodeModuleJsDepth: 2,
allowSyntheticDefaultImports: true,
allowJs: true
};
Reflect.set(compilerOptions, 'jsx', 'react-jsx');
return {
compilerOptions: {
maxNodeModuleJsDepth: 2,
allowSyntheticDefaultImports: true,
allowJs: true,
},
compilerOptions,
include: ['src'],
};
}