Skip to content

Commit

Permalink
feat: basic template imports support (<template> tag and gts, gjs fil…
Browse files Browse the repository at this point in the history
…es) (#350)

* draft

* file renages test

* range walker test

* add bounds tests

* tests for expected corner cases

* smoke template locals test

* improve subtract logic

* cleanup

* scope strip example

* use char placeholder

* scope locator

* implement placeholders

* extract scope bindings

* support inline scope extraction

* basic completion provider based on js scope

* basic unit test for completion provider

* get test working

* absolute content part support

* query legacy template completion logic for results

* fix snapshot

* fix linting error

* support {gts,gjs} in tests, components

* use emoji for pointer

* get basic autocomplete working

* support non-instrumented runs

* add gts/gjs integration tests

* add template lint support

* add gts and gjs to list of supported extensions

* fix absolute content boundaries

* hbs source convert

* attempt to get autofixes working
  • Loading branch information
lifeart authored Feb 5, 2022
1 parent 34798c9 commit 778c9cb
Show file tree
Hide file tree
Showing 19 changed files with 1,309 additions and 169 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"main": "lib/index.js",
"typings": "lib/index.d.ts",
"dependencies": {
"@glimmer/syntax": "^0.73.1",
"@glimmer/syntax": "^0.83.1",
"@lifeart/ember-extract-inline-templates": "2.1.0",
"ast-types": "^0.14.2",
"dag-map": "^2.0.2",
Expand Down
25 changes: 24 additions & 1 deletion src/builtin-addons/core/code-actions/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { searchAndExtractHbs } from '@lifeart/ember-extract-inline-templates';
import { parseScriptFile } from 'ember-meta-explorer';
import { toPosition } from '../../../estree-utils';
import ASTPath from '../../../glimmer-utils';
import { getFileRanges, RangeWalker } from '../../../utils/glimmer-script';
import { toHbsSource } from '../../../utils/diagnostic';
export interface INodeSelectionInfo {
selection: string | undefined;
location: SourceLocation;
Expand Down Expand Up @@ -42,7 +44,7 @@ function findValidNodeSelection(focusPath: ASTPath): null | INodeSelectionInfo {
return null;
}

const extensionsToLint: string[] = ['.hbs', '.js', '.ts'];
const extensionsToLint: string[] = ['.hbs', '.js', '.ts', '.gts', 'gjs'];

export default class BaseCodeActionProvider implements AddonAPI {
public server!: Server;
Expand All @@ -62,6 +64,27 @@ export default class BaseCodeActionProvider implements AddonAPI {

if (extension === '.hbs') {
ast = this.server.templateCompletionProvider.getAST(documentContent);
} else if (extension === '.gjs' || extension === '.gts') {
const ranges = getFileRanges(documentContent);

const rangeWalker = new RangeWalker(ranges);
const templates = rangeWalker.templates();

if (!templates.length) {
return null;
}

const t = templates[0];

const source = toHbsSource({
startLine: t.loc.start.line,
startColumn: t.loc.start.character,
endColumn: t.loc.end.character,
endLine: t.loc.end.line,
template: t.content,
});

ast = this.server.templateCompletionProvider.getAST(source);
} else {
const templateData = searchAndExtractHbs(documentContent, {
parse(source: string) {
Expand Down
95 changes: 95 additions & 0 deletions src/completion-provider/glimmer-script-completion-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { CompletionItem, TextDocumentPositionParams } from 'vscode-languageserver/node';
import Server from '../server';
import { getFileRanges, RangeWalker, getPlaceholderPathFromAst, getScope } from '../utils/glimmer-script';
import { parseScriptFile as parse } from 'ember-meta-explorer';
import { containsPosition, toPosition } from '../estree-utils';
import { getFocusPath } from '../utils/glimmer-template';
import { TextDocument } from 'vscode-languageserver-textdocument';

export default class GlimmerScriptCompletionProvider {
constructor(private server: Server) {}
async provideCompletions(params: TextDocumentPositionParams): Promise<CompletionItem[]> {
const document = this.server.documents.get(params.textDocument.uri);

if (!document) {
return [];
}

const rawContent = document.getText();

const ranges = getFileRanges(rawContent);

let rangeWalker = new RangeWalker(ranges);

// strip not needed scopes example
rangeWalker = rangeWalker.subtract(rangeWalker.hbsInlineComments(true));
rangeWalker = rangeWalker.subtract(rangeWalker.hbsComments(true));
rangeWalker = rangeWalker.subtract(rangeWalker.htmlComments(true));

const templates = rangeWalker.templates(true);

const cleanScriptWalker = rangeWalker.subtract(templates, true);

const templateForPosition = templates.find((el) => {
return containsPosition(
{
start: {
line: el.loc.start.line,
column: el.loc.start.character,
},
end: {
line: el.loc.end.line,
column: el.loc.end.character,
},
},
toPosition(params.position)
);
});

const ast = parse(cleanScriptWalker.content, {
sourceType: 'module',
});

if (templateForPosition) {
const placeholder = getPlaceholderPathFromAst(ast, templateForPosition.key);

if (!placeholder) {
return [];
}

const results: CompletionItem[] = [];
const scopes = getScope(placeholder.scope);

scopes.forEach((name) => {
results.push({
label: name,
});
});

const synthDoc = TextDocument.create(document.uri, 'handlebars', document.version, templateForPosition.absoluteContent);
const info = getFocusPath(synthDoc, params.position);

if (!info) {
return results;
}

const project = this.server.projectRoots.projectForUri(params.textDocument.uri);

if (!project) {
return results;
}

const legacyResults = await this.server.templateCompletionProvider.provideCompletionsForFocusPath(info, params.textDocument, params.position, project);

legacyResults.forEach((result) => {
results.push(result);
});

return results;
// do logic to get more meta from js scope for template position
// here we need glimmer logic to collect all available tokens from scope for autocomplete
} else {
return [];
}
}
}
10 changes: 9 additions & 1 deletion src/completion-provider/script-completion-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,24 @@ import { parseScriptFile as parse } from 'ember-meta-explorer';
import { uniqBy } from 'lodash';
import { getExtension } from '../utils/file-extension';
import { logDebugInfo } from '../utils/logger';
import GlimmerScriptCompletionProvider from './glimmer-script-completion-provider';

export default class ScriptCompletionProvider {
constructor(private server: Server) {}
async provideCompletions(params: TextDocumentPositionParams): Promise<CompletionItem[]> {
logDebugInfo('provideCompletions');

if (!['.js', '.ts'].includes(getExtension(params.textDocument) as string)) {
const ext = getExtension(params.textDocument) as string;

if (!['.js', '.ts', '.gjs', '.gts'].includes(ext)) {
return [];
}

if (ext === '.gts' || ext === '.gjs') {
// temporary workaround
return new GlimmerScriptCompletionProvider(this.server).provideCompletions(params);
}

const uri = params.textDocument.uri;
const project = this.server.projectRoots.projectForUri(uri);

Expand Down
170 changes: 45 additions & 125 deletions src/completion-provider/template-completion-provider.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,21 @@
import { CompletionItem, TextDocumentPositionParams, Position, TextDocumentIdentifier } from 'vscode-languageserver/node';
import Server from '../server';
import ASTPath from '../glimmer-utils';
import { toPosition } from '../estree-utils';
import { filter } from 'fuzzaldrin';
import { queryELSAddonsAPIChain } from './../utils/addon-api';
import { preprocess, ASTv1 } from '@glimmer/syntax';
import { getExtension } from '../utils/file-extension';
import { logDebugInfo, logInfo } from '../utils/logger';
import { searchAndExtractHbs } from '@lifeart/ember-extract-inline-templates';
import { TextDocument } from 'vscode-languageserver-textdocument';
import { Position as EsTreePosition } from 'estree';
import { parseScriptFile } from 'ember-meta-explorer';

const extensionsToProvideTemplateCompletions = ['.hbs', '.js', '.ts'];
const PLACEHOLDER = 'ELSCompletionDummy';
import { createFocusPath, extensionsToProvideTemplateCompletions, getFocusPath, getTextForGuessing, PLACEHOLDER } from '../utils/glimmer-template';
import { Project } from '../project';

export default class TemplateCompletionProvider {
constructor(private server: Server) {}
getTextForGuessing(originalText: string, offset: number, PLACEHOLDER: string) {
// logDebugInfo('getTextForGuessing', originalText, offset, PLACEHOLDER);

return originalText.slice(0, offset) + PLACEHOLDER + originalText.slice(offset);
return getTextForGuessing(originalText, offset, PLACEHOLDER);
}
getRoots(doc: TextDocumentIdentifier) {
const project = this.server.projectRoots.projectForUri(doc.uri);
Expand All @@ -35,130 +30,25 @@ export default class TemplateCompletionProvider {
return preprocess(textContent);
}
createFocusPath(ast: any, position: EsTreePosition, validText: string) {
return ASTPath.toPosition(ast, position, validText);
return createFocusPath(ast, position, validText);
}
getFocusPath(
document: TextDocument,
position: Position,
placeholder = PLACEHOLDER
): null | {
focusPath: ASTPath;
originalText: string;
normalPlaceholder: string;
ast: any;
} {
const documentContent = document.getText();
const ext = getExtension(document);

if (!extensionsToProvideTemplateCompletions.includes(ext as string)) {
return null;
}

const originalText =
ext === '.hbs'
? documentContent
: searchAndExtractHbs(documentContent, {
parse(source: string) {
return parseScriptFile(source);
},
});

logDebugInfo('originalText', originalText);

if (originalText.trim().length === 0) {
logDebugInfo('originalText - empty');

return null;
}

const offset = document.offsetAt(position);
let normalPlaceholder: any = placeholder;
let ast: any = {};

const cases = [
PLACEHOLDER + ' />',
PLACEHOLDER,
PLACEHOLDER + '"',
PLACEHOLDER + "'",
// block params autocomplete
PLACEHOLDER + '| />',
PLACEHOLDER + '}} />',
PLACEHOLDER + '"}}',
PLACEHOLDER + '}}',
PLACEHOLDER + '}}{{/' + PLACEHOLDER + '}}',
// {{#}} -> {{# + P}}{{/P + }}
PLACEHOLDER + '}}{{/' + PLACEHOLDER,
PLACEHOLDER + ')}}',
PLACEHOLDER + '))}}',
PLACEHOLDER + ')))}}',
];

let validText = '';

while (cases.length) {
normalPlaceholder = cases.shift();

try {
validText = this.getTextForGuessing(originalText, offset, normalPlaceholder);
ast = this.getAST(validText);
logDebugInfo('validText', validText);
break;
} catch (e) {
// logDebugInfo('parsing-error', this.getTextForGuessing(originalText, offset, normalPlaceholder));
ast = null;
}
}

if (ast === null) {
return null;
}

const focusPath = this.createFocusPath(ast, toPosition(position), validText);

if (!focusPath) {
return null;
}

return {
ast,
focusPath,
originalText,
normalPlaceholder,
};
getFocusPath(document: TextDocument, position: Position, placeholder = PLACEHOLDER) {
return getFocusPath(document, position, placeholder);
}
async provideCompletions(params: TextDocumentPositionParams): Promise<CompletionItem[]> {
logDebugInfo('template:provideCompletions');
const ext = getExtension(params.textDocument);

if (ext !== null && !extensionsToProvideTemplateCompletions.includes(ext)) {
logDebugInfo('template:provideCompletions:unsupportedExtension', ext);

return [];
}

const position = Object.freeze({ ...params.position });
const { project, document } = this.getRoots(params.textDocument);

if (!project || !document) {
logInfo(`No project for file: ${params.textDocument.uri}`);

return [];
}

const { root } = project;
const results = this.getFocusPath(document, position, PLACEHOLDER);

if (!results) {
return [];
}

async provideCompletionsForFocusPath(
results: { focusPath: any; originalText: string; normalPlaceholder: string },
textDocument: TextDocumentIdentifier,
position: Position,
project: Project
) {
const focusPath = results.focusPath;
const originalText = results.originalText;
const normalPlaceholder = results.normalPlaceholder;
const root = project.root;

const completions: CompletionItem[] = await queryELSAddonsAPIChain(project.builtinProviders.completionProviders, root, {
focusPath,
textDocument: params.textDocument,
textDocument,
position,
results: [],
server: this.server,
Expand All @@ -168,7 +58,7 @@ export default class TemplateCompletionProvider {

const addonResults = await queryELSAddonsAPIChain(project.providers.completionProviders, root, {
focusPath,
textDocument: params.textDocument,
textDocument,
position,
results: completions,
server: this.server,
Expand Down Expand Up @@ -210,6 +100,36 @@ export default class TemplateCompletionProvider {
return el;
});
}
async provideCompletions(params: TextDocumentPositionParams): Promise<CompletionItem[]> {
logDebugInfo('template:provideCompletions');
const ext = getExtension(params.textDocument);

// @to-do cleanup this creepy stuff (streamline autocomplete stuff);
const extToHandle = extensionsToProvideTemplateCompletions.filter((e) => e !== '.gts' && e !== '.gjs');

if (ext !== null && !extToHandle.includes(ext)) {
logDebugInfo('template:provideCompletions:unsupportedExtension', ext);

return [];
}

const position = Object.freeze({ ...params.position });
const { project, document } = this.getRoots(params.textDocument);

if (!project || !document) {
logInfo(`No project for file: ${params.textDocument.uri}`);

return [];
}

const results = this.getFocusPath(document, position, PLACEHOLDER);

if (!results) {
return [];
}

return this.provideCompletionsForFocusPath(results, params.textDocument, position, project);
}
}

function getTextPrefix(astPath: ASTPath, normalPlaceholder: string): string {
Expand Down
Loading

0 comments on commit 778c9cb

Please sign in to comment.