Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 122 additions & 6 deletions fontes/completude/lmht-provedor-completude.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,44 @@
import * as vscode from 'vscode';

import modificadoresLmht from '../linguagens/lmht/estruturas';
import atributosLmht from '../linguagens/lmht/atributos';

/**
* Estruturas LMHT que correspondem a tags vazias (_void_) em HTML e, portanto,
* não devem gerar par de abertura e fechamento na completude.
*/
const ESTRUTURAS_VAZIAS = new Set<string>([
'area',
'campo',
'coluna',
'imagem',
'linha-horizontal',
'quebra-linha',
'quebra-linha-oportuna',
'recurso'
]);

/**
* Contexto do cursor no momento da completude, para decidir o que sugerir.
*/
enum ContextoCompletude {
/** Dentro de um comentário: não sugerir nada. */
Comentario,
/** Dentro da abertura de uma tag (após `<nome ...`): sugerir atributos. */
AtributosDeTag,
/** Em posição de estrutura (início de arquivo, após `>` ou após `<`): sugerir tags. */
Estruturas
}

/**
* Provedor de completude para LMHT, sensível ao contexto do cursor.
*
* - Suprime sugestões dentro de comentários `<!-- -->`.
* - Dentro da abertura de uma tag, sugere atributos em vez de estruturas.
* - Em posição de estrutura, sugere as tags LMHT já como _snippet_, inserindo
* `<tag>|</tag>` (ou `<tag />` para estruturas vazias) com o cursor no lugar
* certo.
*/
export class LmhtProvedorCompletude implements vscode.CompletionItemProvider {
provideCompletionItems(
document: vscode.TextDocument,
Expand All @@ -11,15 +48,94 @@ export class LmhtProvedorCompletude implements vscode.CompletionItemProvider {
): vscode.ProviderResult<
vscode.CompletionList<vscode.CompletionItem> | vscode.CompletionItem[]
> {
const todosModificadores: vscode.CompletionItem[] = [];
for (let [chave, valor] of Object.entries(modificadoresLmht)) {
let item = new vscode.CompletionItem(
switch (this.determinarContexto(document, position)) {
case ContextoCompletude.Comentario:
return [];
case ContextoCompletude.AtributosDeTag:
return this.itensAtributos();
case ContextoCompletude.Estruturas:
default:
return this.itensEstruturas();
}
}

/**
* Determina o contexto do cursor analisando o texto do início do documento
* até a posição atual.
*/
private determinarContexto(
document: vscode.TextDocument,
position: vscode.Position
): ContextoCompletude {
const textoAnterior = document.getText(
new vscode.Range(new vscode.Position(0, 0), position)
);

// Comentário: há um `<!--` sem `-->` correspondente antes do cursor.
const ultimoAbreComentario = textoAnterior.lastIndexOf('<!--');
const ultimoFechaComentario = textoAnterior.lastIndexOf('-->');
if (ultimoAbreComentario > ultimoFechaComentario) {
return ContextoCompletude.Comentario;
}

// Abertura de tag: há um `<` (que inicia um nome de tag, não `</` nem
// `<!`) sem o `>` correspondente antes do cursor, e já existe pelo menos
// um espaço após o nome — ou seja, estamos na região de atributos.
const ultimoMenor = textoAnterior.lastIndexOf('<');
const ultimoMaior = textoAnterior.lastIndexOf('>');
if (ultimoMenor > ultimoMaior) {
const trechoTag = textoAnterior.substring(ultimoMenor);
const ehAberturaDeTag = /^<[A-Za-zÀ-ú]/.test(trechoTag);
const jaTemEspaco = /\s/.test(trechoTag);
if (ehAberturaDeTag && jaTemEspaco) {
return ContextoCompletude.AtributosDeTag;
}
}

return ContextoCompletude.Estruturas;
}

/**
* Itens de completude para estruturas (tags), inseridas como _snippet_.
*/
private itensEstruturas(): vscode.CompletionItem[] {
const itens: vscode.CompletionItem[] = [];
for (const [chave, valor] of Object.entries(modificadoresLmht)) {
const item = new vscode.CompletionItem(
chave,
vscode.CompletionItemKind.Property
);
item.documentation = `Equivalente em HTML: ${valor.nomeHtml}`;
todosModificadores.push(item);
item.documentation = new vscode.MarkdownString(
`Equivalente em HTML: \`${valor.nomeHtml}\``
);

if (ESTRUTURAS_VAZIAS.has(chave)) {
item.insertText = new vscode.SnippetString(`<${chave} $0/>`);
} else {
item.insertText = new vscode.SnippetString(`<${chave}>$0</${chave}>`);
}

itens.push(item);
}
return itens;
}

/**
* Itens de completude para atributos, sugeridos dentro da abertura de tag.
*/
private itensAtributos(): vscode.CompletionItem[] {
const itens: vscode.CompletionItem[] = [];
for (const [chave, valor] of Object.entries(atributosLmht)) {
const item = new vscode.CompletionItem(
chave,
vscode.CompletionItemKind.Field
);
item.documentation = new vscode.MarkdownString(
`Equivalente em HTML: \`${valor.nomeHtml}\``
);
item.insertText = new vscode.SnippetString(`${chave}="$0"`);
itens.push(item);
}
return todosModificadores;
return itens;
}
}
159 changes: 98 additions & 61 deletions testes/completude/lmht-provedor-completude.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,24 @@ jest.mock('vscode', () => ({
Property: 9,
Function: 2,
Interface: 7,
Field: 4,
},
CompletionItem: class CompletionItem {
constructor(public label: string, public kind?: number) {}
documentation: any;
insertText: any;
},
SnippetString: class SnippetString {
constructor(public value: string) {}
},
MarkdownString: class MarkdownString {
constructor(public value: string = '') {}
},
Position: class Position {
constructor(public line: number, public character: number) {}
},
Range: class Range {
constructor(public start: any, public end: any) {}
},
}), { virtual: true });

Expand All @@ -21,18 +35,40 @@ jest.mock('../../fontes/linguagens/lmht/estruturas', () => ({
'divisao': { nomeHtml: 'div' },
'titulo1': { nomeHtml: 'h1' },
'ancora': { nomeHtml: 'a' },
'imagem': { nomeHtml: 'img' },
}
}));

jest.mock('../../fontes/linguagens/lmht/atributos', () => ({
__esModule: true,
default: {
'classe': { nomeHtml: 'class' },
'id': { nomeHtml: 'id' },
'fonte': { nomeHtml: 'src' },
}
}));

import { LmhtProvedorCompletude } from '../../fontes/completude/lmht-provedor-completude';

/**
* Documento simulado. `getText(range)` devolve o texto do início até o fim
* do range, replicando o comportamento usado pelo provedor para analisar o
* contexto do cursor.
*/
function criarDocumento(linhas: string[] = ['']): any {
const texto = linhas.join('\n');
return {
lineAt: jest.fn((linha: any) => {
const idx = typeof linha === 'number' ? linha : linha.line ?? 0;
return { text: linhas[idx] ?? '' };
getText: jest.fn((range?: any) => {
if (!range) {
return texto;
}
let offset = 0;
for (let i = 0; i < range.end.line; i++) {
offset += linhas[i].length + 1;
}
offset += range.end.character;
return texto.substring(0, offset);
}),
getText: jest.fn(() => linhas.join('\n')),
};
}

Expand All @@ -55,78 +91,79 @@ describe('LmhtProvedorCompletude', () => {
expect(typeof provedor.provideCompletionItems).toBe('function');
});

it('retorna array de completion items', () => {
const doc = criarDocumento(['']);
const pos = criarPosicao(0, 0);
const items = provedor.provideCompletionItems(doc, pos, mockToken, mockContext);
expect(Array.isArray(items)).toBe(true);
expect(items.length).toBeGreaterThan(0);
});

it('todos os itens têm label definido', () => {
const doc = criarDocumento(['']);
const pos = criarPosicao(0, 0);
const items = provedor.provideCompletionItems(doc, pos, mockToken, mockContext);
items.forEach((item: any) => {
expect(item.label).toBeDefined();
expect(typeof item.label).toBe('string');
describe('contexto de estruturas', () => {
it('sugere estruturas fora de qualquer tag', () => {
const items = provedor.provideCompletionItems(
criarDocumento(['']), criarPosicao(0, 0), mockToken, mockContext
);
expect(Array.isArray(items)).toBe(true);
expect(items.length).toBe(5);
expect(items.some((i: any) => i.label === 'paragrafo')).toBe(true);
expect(items.some((i: any) => i.label === 'titulo1')).toBe(true);
});
});

it('todos os itens são do tipo Property', () => {
const doc = criarDocumento(['']);
const pos = criarPosicao(0, 0);
const items = provedor.provideCompletionItems(doc, pos, mockToken, mockContext);
items.forEach((item: any) => {
expect(item.kind).toBe(vscode.CompletionItemKind.Property);
it('sugere estruturas após o fechamento de uma tag', () => {
const doc = criarDocumento(['<corpo>']);
const items = provedor.provideCompletionItems(doc, criarPosicao(0, 7), mockToken, mockContext);
expect(items.length).toBe(5);
});
});

it('todos os itens têm documentação', () => {
const doc = criarDocumento(['']);
const pos = criarPosicao(0, 0);
const items = provedor.provideCompletionItems(doc, pos, mockToken, mockContext);
items.forEach((item: any) => {
expect(item.documentation).toBeDefined();
it('insere estrutura como par de abertura e fechamento com cursor no meio', () => {
const doc = criarDocumento(['<corpo>']);
const items = provedor.provideCompletionItems(doc, criarPosicao(0, 7), mockToken, mockContext);
const titulo1 = items.find((i: any) => i.label === 'titulo1');
expect(titulo1.insertText.value).toBe('<titulo1>$0</titulo1>');
});
});

describe('estruturas mockadas', () => {
it('inclui paragrafo', () => {
const doc = criarDocumento(['']);
const pos = criarPosicao(0, 0);
const items = provedor.provideCompletionItems(doc, pos, mockToken, mockContext);
expect(items.some((i: any) => i.label === 'paragrafo')).toBe(true);
it('insere estrutura vazia (void) sem tag de fechamento', () => {
const doc = criarDocumento(['<corpo>']);
const items = provedor.provideCompletionItems(doc, criarPosicao(0, 7), mockToken, mockContext);
const imagem = items.find((i: any) => i.label === 'imagem');
expect(imagem.insertText.value).toBe('<imagem $0/>');
});

it('inclui divisao', () => {
const doc = criarDocumento(['']);
const pos = criarPosicao(0, 0);
const items = provedor.provideCompletionItems(doc, pos, mockToken, mockContext);
expect(items.some((i: any) => i.label === 'divisao')).toBe(true);
it('todos os itens de estrutura têm label, documentação e insertText', () => {
const items = provedor.provideCompletionItems(
criarDocumento(['']), criarPosicao(0, 0), mockToken, mockContext
);
items.forEach((item: any) => {
expect(typeof item.label).toBe('string');
expect(item.documentation).toBeDefined();
expect(item.insertText).toBeDefined();
});
});
});

it('inclui titulo1', () => {
const doc = criarDocumento(['']);
const pos = criarPosicao(0, 0);
const items = provedor.provideCompletionItems(doc, pos, mockToken, mockContext);
expect(items.some((i: any) => i.label === 'titulo1')).toBe(true);
describe('contexto de atributos', () => {
it('sugere atributos dentro da abertura de uma tag', () => {
const doc = criarDocumento(['<imagem ']);
const items = provedor.provideCompletionItems(doc, criarPosicao(0, 8), mockToken, mockContext);
expect(items.length).toBe(3);
expect(items.some((i: any) => i.label === 'classe')).toBe(true);
expect(items.some((i: any) => i.label === 'fonte')).toBe(true);
// Não deve sugerir estruturas aqui.
expect(items.some((i: any) => i.label === 'paragrafo')).toBe(false);
});

it('inclui ancora', () => {
const doc = criarDocumento(['']);
const pos = criarPosicao(0, 0);
const items = provedor.provideCompletionItems(doc, pos, mockToken, mockContext);
expect(items.some((i: any) => i.label === 'ancora')).toBe(true);
it('insere atributo com aspas e cursor entre elas', () => {
const doc = criarDocumento(['<imagem ']);
const items = provedor.provideCompletionItems(doc, criarPosicao(0, 8), mockToken, mockContext);
const classe = items.find((i: any) => i.label === 'classe');
expect(classe.insertText.value).toBe('classe="$0"');
});
});

describe('contagem total de itens', () => {
it('retorna todas as estruturas mockadas', () => {
const doc = criarDocumento(['']);
const pos = criarPosicao(0, 0);
const items = provedor.provideCompletionItems(doc, pos, mockToken, mockContext);
expect(items.length).toBe(4);
describe('contexto de comentário', () => {
it('não sugere nada dentro de um comentário', () => {
const doc = criarDocumento(['<!-- ']);
const items = provedor.provideCompletionItems(doc, criarPosicao(0, 5), mockToken, mockContext);
expect(items).toEqual([]);
});

it('volta a sugerir estruturas após o fechamento do comentário', () => {
const doc = criarDocumento(['<!-- nota --> ']);
const items = provedor.provideCompletionItems(doc, criarPosicao(0, 14), mockToken, mockContext);
expect(items.length).toBe(5);
});
});
});
Loading