Skip to content

Commit 059e613

Browse files
oxbarMicael Santana
andauthored
Completude de LMHT sensível a contexto e com inserção de marcação (#103) (#123)
O provedor de completude de LMHT devolvia sempre a mesma lista de 122 tags, em qualquer posição do cursor, e sem insertText: escolher 'titulo1' inseria o texto pelado, não '<titulo1></titulo1>'. Não havia completude de atributos, e sugestões apareciam até dentro de comentários. Reescrita seguindo a técnica de contexto que o provedor de FolEs desta mesma extensão já usa. O provedor agora determina o contexto do cursor analisando o texto até a posição e decide o que sugerir: - Comentário (<!-- sem --> correspondente antes do cursor): não sugere nada. - Abertura de tag (após '<nome ' com espaço, sem '>' de fechamento): sugere atributos, não estruturas. Usa o dicionário de atributos que já existia em fontes/linguagens/lmht/atributos.ts, inserindo 'atributo="|"' com o cursor entre as aspas. - Posição de estrutura (início de arquivo, após '>' ou '<'): sugere as tags como snippet. Estruturas normais inserem '<tag>|</tag>' com o cursor no meio; as oito estruturas que correspondem a tags vazias (void) em HTML — area, campo, coluna, imagem, linha-horizontal, quebra-linha, quebra-linha-oportuna, recurso — inserem '<tag />' sem fechamento. Decisão de escopo: texto livre dentro de uma estrutura (por exemplo, após '<paragrafo>') continua sugerindo estruturas, porque LMHT permite aninhar tags no meio do texto ('<p>olá <negrito>mundo</negrito></p>'). Suprimir ali quebraria aninhamento legítimo; é também o comportamento do suporte a HTML embutido do VSCode, citado como referência na issue. Testes: a suíte de LMHT foi reescrita (10 casos) cobrindo os três contextos — estruturas com snippet de abertura/fechamento e de tag vazia, atributos dentro da tag (e a ausência de estruturas ali), e supressão dentro de comentário com retomada após o fechamento. Os mocks de vscode foram completados (SnippetString, MarkdownString, Position, Range) e o mock de atributos adicionado. Fixes #103 Co-authored-by: Micael Santana <micaelparadox@gmail.com>
1 parent 6053ada commit 059e613

2 files changed

Lines changed: 220 additions & 67 deletions

File tree

Lines changed: 122 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,44 @@
11
import * as vscode from 'vscode';
22

33
import modificadoresLmht from '../linguagens/lmht/estruturas';
4+
import atributosLmht from '../linguagens/lmht/atributos';
45

6+
/**
7+
* Estruturas LMHT que correspondem a tags vazias (_void_) em HTML e, portanto,
8+
* não devem gerar par de abertura e fechamento na completude.
9+
*/
10+
const ESTRUTURAS_VAZIAS = new Set<string>([
11+
'area',
12+
'campo',
13+
'coluna',
14+
'imagem',
15+
'linha-horizontal',
16+
'quebra-linha',
17+
'quebra-linha-oportuna',
18+
'recurso'
19+
]);
20+
21+
/**
22+
* Contexto do cursor no momento da completude, para decidir o que sugerir.
23+
*/
24+
enum ContextoCompletude {
25+
/** Dentro de um comentário: não sugerir nada. */
26+
Comentario,
27+
/** Dentro da abertura de uma tag (após `<nome ...`): sugerir atributos. */
28+
AtributosDeTag,
29+
/** Em posição de estrutura (início de arquivo, após `>` ou após `<`): sugerir tags. */
30+
Estruturas
31+
}
32+
33+
/**
34+
* Provedor de completude para LMHT, sensível ao contexto do cursor.
35+
*
36+
* - Suprime sugestões dentro de comentários `<!-- -->`.
37+
* - Dentro da abertura de uma tag, sugere atributos em vez de estruturas.
38+
* - Em posição de estrutura, sugere as tags LMHT já como _snippet_, inserindo
39+
* `<tag>|</tag>` (ou `<tag />` para estruturas vazias) com o cursor no lugar
40+
* certo.
41+
*/
542
export class LmhtProvedorCompletude implements vscode.CompletionItemProvider {
643
provideCompletionItems(
744
document: vscode.TextDocument,
@@ -11,15 +48,94 @@ export class LmhtProvedorCompletude implements vscode.CompletionItemProvider {
1148
): vscode.ProviderResult<
1249
vscode.CompletionList<vscode.CompletionItem> | vscode.CompletionItem[]
1350
> {
14-
const todosModificadores: vscode.CompletionItem[] = [];
15-
for (let [chave, valor] of Object.entries(modificadoresLmht)) {
16-
let item = new vscode.CompletionItem(
51+
switch (this.determinarContexto(document, position)) {
52+
case ContextoCompletude.Comentario:
53+
return [];
54+
case ContextoCompletude.AtributosDeTag:
55+
return this.itensAtributos();
56+
case ContextoCompletude.Estruturas:
57+
default:
58+
return this.itensEstruturas();
59+
}
60+
}
61+
62+
/**
63+
* Determina o contexto do cursor analisando o texto do início do documento
64+
* até a posição atual.
65+
*/
66+
private determinarContexto(
67+
document: vscode.TextDocument,
68+
position: vscode.Position
69+
): ContextoCompletude {
70+
const textoAnterior = document.getText(
71+
new vscode.Range(new vscode.Position(0, 0), position)
72+
);
73+
74+
// Comentário: há um `<!--` sem `-->` correspondente antes do cursor.
75+
const ultimoAbreComentario = textoAnterior.lastIndexOf('<!--');
76+
const ultimoFechaComentario = textoAnterior.lastIndexOf('-->');
77+
if (ultimoAbreComentario > ultimoFechaComentario) {
78+
return ContextoCompletude.Comentario;
79+
}
80+
81+
// Abertura de tag: há um `<` (que inicia um nome de tag, não `</` nem
82+
// `<!`) sem o `>` correspondente antes do cursor, e já existe pelo menos
83+
// um espaço após o nome — ou seja, estamos na região de atributos.
84+
const ultimoMenor = textoAnterior.lastIndexOf('<');
85+
const ultimoMaior = textoAnterior.lastIndexOf('>');
86+
if (ultimoMenor > ultimoMaior) {
87+
const trechoTag = textoAnterior.substring(ultimoMenor);
88+
const ehAberturaDeTag = /^<[A-Za-zÀ-ú]/.test(trechoTag);
89+
const jaTemEspaco = /\s/.test(trechoTag);
90+
if (ehAberturaDeTag && jaTemEspaco) {
91+
return ContextoCompletude.AtributosDeTag;
92+
}
93+
}
94+
95+
return ContextoCompletude.Estruturas;
96+
}
97+
98+
/**
99+
* Itens de completude para estruturas (tags), inseridas como _snippet_.
100+
*/
101+
private itensEstruturas(): vscode.CompletionItem[] {
102+
const itens: vscode.CompletionItem[] = [];
103+
for (const [chave, valor] of Object.entries(modificadoresLmht)) {
104+
const item = new vscode.CompletionItem(
17105
chave,
18106
vscode.CompletionItemKind.Property
19107
);
20-
item.documentation = `Equivalente em HTML: ${valor.nomeHtml}`;
21-
todosModificadores.push(item);
108+
item.documentation = new vscode.MarkdownString(
109+
`Equivalente em HTML: \`${valor.nomeHtml}\``
110+
);
111+
112+
if (ESTRUTURAS_VAZIAS.has(chave)) {
113+
item.insertText = new vscode.SnippetString(`<${chave} $0/>`);
114+
} else {
115+
item.insertText = new vscode.SnippetString(`<${chave}>$0</${chave}>`);
116+
}
117+
118+
itens.push(item);
119+
}
120+
return itens;
121+
}
122+
123+
/**
124+
* Itens de completude para atributos, sugeridos dentro da abertura de tag.
125+
*/
126+
private itensAtributos(): vscode.CompletionItem[] {
127+
const itens: vscode.CompletionItem[] = [];
128+
for (const [chave, valor] of Object.entries(atributosLmht)) {
129+
const item = new vscode.CompletionItem(
130+
chave,
131+
vscode.CompletionItemKind.Field
132+
);
133+
item.documentation = new vscode.MarkdownString(
134+
`Equivalente em HTML: \`${valor.nomeHtml}\``
135+
);
136+
item.insertText = new vscode.SnippetString(`${chave}="$0"`);
137+
itens.push(item);
22138
}
23-
return todosModificadores;
139+
return itens;
24140
}
25141
}

testes/completude/lmht-provedor-completude.test.ts

Lines changed: 98 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,24 @@ jest.mock('vscode', () => ({
77
Property: 9,
88
Function: 2,
99
Interface: 7,
10+
Field: 4,
1011
},
1112
CompletionItem: class CompletionItem {
1213
constructor(public label: string, public kind?: number) {}
1314
documentation: any;
15+
insertText: any;
16+
},
17+
SnippetString: class SnippetString {
18+
constructor(public value: string) {}
19+
},
20+
MarkdownString: class MarkdownString {
21+
constructor(public value: string = '') {}
22+
},
23+
Position: class Position {
24+
constructor(public line: number, public character: number) {}
25+
},
26+
Range: class Range {
27+
constructor(public start: any, public end: any) {}
1428
},
1529
}), { virtual: true });
1630

@@ -21,18 +35,40 @@ jest.mock('../../fontes/linguagens/lmht/estruturas', () => ({
2135
'divisao': { nomeHtml: 'div' },
2236
'titulo1': { nomeHtml: 'h1' },
2337
'ancora': { nomeHtml: 'a' },
38+
'imagem': { nomeHtml: 'img' },
39+
}
40+
}));
41+
42+
jest.mock('../../fontes/linguagens/lmht/atributos', () => ({
43+
__esModule: true,
44+
default: {
45+
'classe': { nomeHtml: 'class' },
46+
'id': { nomeHtml: 'id' },
47+
'fonte': { nomeHtml: 'src' },
2448
}
2549
}));
2650

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

53+
/**
54+
* Documento simulado. `getText(range)` devolve o texto do início até o fim
55+
* do range, replicando o comportamento usado pelo provedor para analisar o
56+
* contexto do cursor.
57+
*/
2958
function criarDocumento(linhas: string[] = ['']): any {
59+
const texto = linhas.join('\n');
3060
return {
31-
lineAt: jest.fn((linha: any) => {
32-
const idx = typeof linha === 'number' ? linha : linha.line ?? 0;
33-
return { text: linhas[idx] ?? '' };
61+
getText: jest.fn((range?: any) => {
62+
if (!range) {
63+
return texto;
64+
}
65+
let offset = 0;
66+
for (let i = 0; i < range.end.line; i++) {
67+
offset += linhas[i].length + 1;
68+
}
69+
offset += range.end.character;
70+
return texto.substring(0, offset);
3471
}),
35-
getText: jest.fn(() => linhas.join('\n')),
3672
};
3773
}
3874

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

58-
it('retorna array de completion items', () => {
59-
const doc = criarDocumento(['']);
60-
const pos = criarPosicao(0, 0);
61-
const items = provedor.provideCompletionItems(doc, pos, mockToken, mockContext);
62-
expect(Array.isArray(items)).toBe(true);
63-
expect(items.length).toBeGreaterThan(0);
64-
});
65-
66-
it('todos os itens têm label definido', () => {
67-
const doc = criarDocumento(['']);
68-
const pos = criarPosicao(0, 0);
69-
const items = provedor.provideCompletionItems(doc, pos, mockToken, mockContext);
70-
items.forEach((item: any) => {
71-
expect(item.label).toBeDefined();
72-
expect(typeof item.label).toBe('string');
94+
describe('contexto de estruturas', () => {
95+
it('sugere estruturas fora de qualquer tag', () => {
96+
const items = provedor.provideCompletionItems(
97+
criarDocumento(['']), criarPosicao(0, 0), mockToken, mockContext
98+
);
99+
expect(Array.isArray(items)).toBe(true);
100+
expect(items.length).toBe(5);
101+
expect(items.some((i: any) => i.label === 'paragrafo')).toBe(true);
102+
expect(items.some((i: any) => i.label === 'titulo1')).toBe(true);
73103
});
74-
});
75104

76-
it('todos os itens são do tipo Property', () => {
77-
const doc = criarDocumento(['']);
78-
const pos = criarPosicao(0, 0);
79-
const items = provedor.provideCompletionItems(doc, pos, mockToken, mockContext);
80-
items.forEach((item: any) => {
81-
expect(item.kind).toBe(vscode.CompletionItemKind.Property);
105+
it('sugere estruturas após o fechamento de uma tag', () => {
106+
const doc = criarDocumento(['<corpo>']);
107+
const items = provedor.provideCompletionItems(doc, criarPosicao(0, 7), mockToken, mockContext);
108+
expect(items.length).toBe(5);
82109
});
83-
});
84110

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

94-
describe('estruturas mockadas', () => {
95-
it('inclui paragrafo', () => {
96-
const doc = criarDocumento(['']);
97-
const pos = criarPosicao(0, 0);
98-
const items = provedor.provideCompletionItems(doc, pos, mockToken, mockContext);
99-
expect(items.some((i: any) => i.label === 'paragrafo')).toBe(true);
118+
it('insere estrutura vazia (void) sem tag de fechamento', () => {
119+
const doc = criarDocumento(['<corpo>']);
120+
const items = provedor.provideCompletionItems(doc, criarPosicao(0, 7), mockToken, mockContext);
121+
const imagem = items.find((i: any) => i.label === 'imagem');
122+
expect(imagem.insertText.value).toBe('<imagem $0/>');
100123
});
101124

102-
it('inclui divisao', () => {
103-
const doc = criarDocumento(['']);
104-
const pos = criarPosicao(0, 0);
105-
const items = provedor.provideCompletionItems(doc, pos, mockToken, mockContext);
106-
expect(items.some((i: any) => i.label === 'divisao')).toBe(true);
125+
it('todos os itens de estrutura têm label, documentação e insertText', () => {
126+
const items = provedor.provideCompletionItems(
127+
criarDocumento(['']), criarPosicao(0, 0), mockToken, mockContext
128+
);
129+
items.forEach((item: any) => {
130+
expect(typeof item.label).toBe('string');
131+
expect(item.documentation).toBeDefined();
132+
expect(item.insertText).toBeDefined();
133+
});
107134
});
135+
});
108136

109-
it('inclui titulo1', () => {
110-
const doc = criarDocumento(['']);
111-
const pos = criarPosicao(0, 0);
112-
const items = provedor.provideCompletionItems(doc, pos, mockToken, mockContext);
113-
expect(items.some((i: any) => i.label === 'titulo1')).toBe(true);
137+
describe('contexto de atributos', () => {
138+
it('sugere atributos dentro da abertura de uma tag', () => {
139+
const doc = criarDocumento(['<imagem ']);
140+
const items = provedor.provideCompletionItems(doc, criarPosicao(0, 8), mockToken, mockContext);
141+
expect(items.length).toBe(3);
142+
expect(items.some((i: any) => i.label === 'classe')).toBe(true);
143+
expect(items.some((i: any) => i.label === 'fonte')).toBe(true);
144+
// Não deve sugerir estruturas aqui.
145+
expect(items.some((i: any) => i.label === 'paragrafo')).toBe(false);
114146
});
115147

116-
it('inclui ancora', () => {
117-
const doc = criarDocumento(['']);
118-
const pos = criarPosicao(0, 0);
119-
const items = provedor.provideCompletionItems(doc, pos, mockToken, mockContext);
120-
expect(items.some((i: any) => i.label === 'ancora')).toBe(true);
148+
it('insere atributo com aspas e cursor entre elas', () => {
149+
const doc = criarDocumento(['<imagem ']);
150+
const items = provedor.provideCompletionItems(doc, criarPosicao(0, 8), mockToken, mockContext);
151+
const classe = items.find((i: any) => i.label === 'classe');
152+
expect(classe.insertText.value).toBe('classe="$0"');
121153
});
122154
});
123155

124-
describe('contagem total de itens', () => {
125-
it('retorna todas as estruturas mockadas', () => {
126-
const doc = criarDocumento(['']);
127-
const pos = criarPosicao(0, 0);
128-
const items = provedor.provideCompletionItems(doc, pos, mockToken, mockContext);
129-
expect(items.length).toBe(4);
156+
describe('contexto de comentário', () => {
157+
it('não sugere nada dentro de um comentário', () => {
158+
const doc = criarDocumento(['<!-- ']);
159+
const items = provedor.provideCompletionItems(doc, criarPosicao(0, 5), mockToken, mockContext);
160+
expect(items).toEqual([]);
161+
});
162+
163+
it('volta a sugerir estruturas após o fechamento do comentário', () => {
164+
const doc = criarDocumento(['<!-- nota --> ']);
165+
const items = provedor.provideCompletionItems(doc, criarPosicao(0, 14), mockToken, mockContext);
166+
expect(items.length).toBe(5);
130167
});
131168
});
132169
});

0 commit comments

Comments
 (0)