fix(language-service): Autocomplete block keywords in more cases (#52198)

Previously, autocompletions were not available in two main cases. We correct them.

1. Autocompletions immediately after `@` were usually not working, for example `foo @|`. We fix this by causing the lexer to not consider the `@` part of the text node.
2. Autocompletions such as `@\nfoo`, where a newline follows a bare `@`, were not working because the language service visitor considered us inside the subsequent text node. We fix this by adding a block name span for the block keyword, and special-case whether we are completing inside the name span. If we are, we don't continue to the following text node.

PR Close #52198
This commit is contained in:
Dylan Hunn 2023-10-12 16:31:57 -07:00
parent 54766fb35f
commit e6affeff61
11 changed files with 1323 additions and 29 deletions

View file

@ -89,8 +89,8 @@ export class Comment implements BaseNode {
export class Block implements BaseNode {
constructor(
public name: string, public parameters: BlockParameter[], public children: Node[],
public sourceSpan: ParseSourceSpan, public startSourceSpan: ParseSourceSpan,
public endSourceSpan: ParseSourceSpan|null = null) {}
public sourceSpan: ParseSourceSpan, public nameSpan: ParseSourceSpan,
public startSourceSpan: ParseSourceSpan, public endSourceSpan: ParseSourceSpan|null = null) {}
visit(visitor: Visitor, context: any) {
return visitor.visitBlock(this, context);

View file

@ -101,7 +101,7 @@ export class WhitespaceVisitor implements html.Visitor {
visitBlock(block: html.Block, context: any): any {
return new html.Block(
block.name, block.parameters, visitAllWithSiblings(this, block.children), block.sourceSpan,
block.startSourceSpan, block.endSourceSpan);
block.nameSpan, block.startSourceSpan, block.endSourceSpan);
}
visitBlockParameter(parameter: html.BlockParameter, context: any) {

View file

@ -91,7 +91,7 @@ class _Expander implements html.Visitor {
visitBlock(block: html.Block, context: any) {
return new html.Block(
block.name, block.parameters, html.visitAll(this, block.children), block.sourceSpan,
block.startSourceSpan, block.endSourceSpan);
block.nameSpan, block.startSourceSpan, block.endSourceSpan);
}
visitBlockParameter(parameter: html.BlockParameter, context: any) {

View file

@ -918,7 +918,7 @@ class _Tokenizer {
}
if (this._tokenizeBlocks && !this._inInterpolation && !this._isInExpansion() &&
(this._isBlockStart() || this._cursor.peek() === chars.$RBRACE)) {
(this._cursor.peek() === chars.$AT || this._cursor.peek() === chars.$RBRACE)) {
return true;
}
@ -944,19 +944,6 @@ class _Tokenizer {
return false;
}
private _isBlockStart(): boolean {
if (this._tokenizeBlocks && this._cursor.peek() === chars.$AT) {
const tmp = this._cursor.clone();
// If it is, also verify that the next character is a valid block identifier.
tmp.advance();
if (isBlockNameChar(tmp.peek())) {
return true;
}
}
return false;
}
private _readUntil(char: number): string {
const start = this._cursor.clone();
this._attemptUntilChar(char);

View file

@ -474,7 +474,7 @@ class _TreeBuilder {
const span = new ParseSourceSpan(token.sourceSpan.start, end, token.sourceSpan.fullStart);
// Create a separate `startSpan` because `span` will be modified when there is an `end` span.
const startSpan = new ParseSourceSpan(token.sourceSpan.start, end, token.sourceSpan.fullStart);
const block = new html.Block(token.parts[0], parameters, [], span, startSpan);
const block = new html.Block(token.parts[0], parameters, [], span, token.sourceSpan, startSpan);
this._pushContainer(block, false);
}
@ -500,7 +500,7 @@ class _TreeBuilder {
const span = new ParseSourceSpan(token.sourceSpan.start, end, token.sourceSpan.fullStart);
// Create a separate `startSpan` because `span` will be modified when there is an `end` span.
const startSpan = new ParseSourceSpan(token.sourceSpan.start, end, token.sourceSpan.fullStart);
const block = new html.Block(token.parts[0], parameters, [], span, startSpan);
const block = new html.Block(token.parts[0], parameters, [], span, token.sourceSpan, startSpan);
this._pushContainer(block, false);
// Incomplete blocks don't have children so we close them immediately and report an error.

View file

@ -315,7 +315,8 @@ export class IfBlockBranch implements Node {
}
export class UnknownBlock implements Node {
constructor(public name: string, public sourceSpan: ParseSourceSpan) {}
constructor(
public name: string, public sourceSpan: ParseSourceSpan, public nameSpan: ParseSourceSpan) {}
visit<Result>(visitor: Visitor<Result>): Result {
return visitor.visitUnknownBlock(this);

View file

@ -163,7 +163,7 @@ export function createSwitchBlock(
}
if ((node.name !== 'case' || node.parameters.length === 0) && node.name !== 'default') {
unknownBlocks.push(new t.UnknownBlock(node.name, node.sourceSpan));
unknownBlocks.push(new t.UnknownBlock(node.name, node.sourceSpan, node.nameSpan));
continue;
}

View file

@ -383,7 +383,7 @@ class HtmlAstToIvyAst implements html.Visitor {
}
result = {
node: new t.UnknownBlock(block.name, block.sourceSpan),
node: new t.UnknownBlock(block.name, block.sourceSpan, block.nameSpan),
errors: [new ParseError(block.sourceSpan, errorMessage)],
};
break;

File diff suppressed because it is too large Load diff

View file

@ -380,6 +380,14 @@ class TemplateTargetVisitor implements t.Visitor {
// nodes.
return;
}
if (last instanceof t.UnknownBlock && isWithin(this.position, last.nameSpan)) {
// Autocompletions such as `@\nfoo`, where a newline follows a bare `@`, would not work
// because the language service visitor sees us inside the subsequent text node. We deal with
// this with using a special-case: if we are completing inside the name span, we don't
// continue to the subsequent text node.
return;
}
if (isTemplateNodeWithKeyAndValue(node) && !isWithinKeyValue(this.position, node)) {
// If cursor is within source span but not within key span or value span,
// do not return the node.

View file

@ -282,12 +282,64 @@ describe('completions', () => {
});
describe('for blocks', () => {
it('at top level', () => {
const {templateFile} = setup(`@`, ``);
templateFile.moveCursorToText('@¦');
const completions = templateFile.getCompletionsAtPosition();
expectContain(
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.BLOCK), ['if']);
const completionPrefixes = ['@', '@i'];
describe(`at top level`, () => {
for (const completionPrefix of completionPrefixes) {
it(`in empty file (with prefix ${completionPrefix})`, () => {
const {templateFile} = setup(`${completionPrefix}`, ``);
templateFile.moveCursorToText(`${completionPrefix}¦`);
const completions = templateFile.getCompletionsAtPosition();
expectContain(
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.BLOCK),
['if']);
});
it(`after text (with prefix ${completionPrefix})`, () => {
const {templateFile} = setup(`foo ${completionPrefix}`, ``);
templateFile.moveCursorToText(`${completionPrefix}¦`);
const completions = templateFile.getCompletionsAtPosition();
expectContain(
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.BLOCK),
['if']);
});
it(`before text (with prefix ${completionPrefix})`, () => {
const {templateFile} = setup(`${completionPrefix} foo`, ``);
templateFile.moveCursorToText(`${completionPrefix}¦`);
const completions = templateFile.getCompletionsAtPosition();
expectContain(
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.BLOCK),
['if']);
});
it(`after newline with text on preceding line (with prefix ${completionPrefix})`, () => {
const {templateFile} = setup(`foo\n${completionPrefix}`, ``);
templateFile.moveCursorToText(`${completionPrefix}¦`);
const completions = templateFile.getCompletionsAtPosition();
expectContain(
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.BLOCK),
['if']);
});
it(`before newline with text on newline (with prefix ${completionPrefix})`, () => {
const {templateFile} = setup(`${completionPrefix}\nfoo`, ``);
templateFile.moveCursorToText(`${completionPrefix}¦`);
const completions = templateFile.getCompletionsAtPosition();
expectContain(
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.BLOCK),
['if']);
});
it(`in a practical case, on its own line (with prefix ${completionPrefix})`, () => {
const {templateFile} = setup(`<div></div>\n ${completionPrefix}\n<span></span>`, ``);
templateFile.moveCursorToText(`${completionPrefix}¦`);
const completions = templateFile.getCompletionsAtPosition();
expectContain(
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.BLOCK),
['if']);
});
}
});
it('inside if', () => {