diff --git a/fontes/analise-codigo/index.ts b/fontes/analise-codigo/index.ts index 275774f..7d93347 100644 --- a/fontes/analise-codigo/index.ts +++ b/fontes/analise-codigo/index.ts @@ -25,6 +25,11 @@ import { AnalisadorSemanticoTestes } from './analisador-semantico-testes'; import { verificarConfiguracaoLincones } from './verificar-lincones'; import { validarLmht } from './validar-lmht'; import { validarFoles } from './validar-foles'; +import type { ContextoProjetoLiquido } from '../liquido/deteccao-projeto-liquido'; +import { + contextoLiquidoCorrespondeAExtensao, + detectarProjetoLiquido, +} from '../liquido/deteccao-projeto-liquido'; const mapaSeveridadeDiagnosticos = { 0: vscode.DiagnosticSeverity.Error, @@ -46,7 +51,8 @@ const mapaSeveridadeDiagnosticos = { */ export async function executarAnalises( documento: vscode.TextDocument, - diagnosticos: vscode.DiagnosticCollection + diagnosticos: vscode.DiagnosticCollection, + detectorProjetoLiquido: typeof detectarProjetoLiquido = detectarProjetoLiquido ): Promise { const extensaoArquivo = documento.languageId === 'delegua-testes' ? 'delegua' @@ -79,6 +85,8 @@ export async function executarAnalises( let resultadoAnalisadorSemantico: RetornoAnalisadorSemanticoInterface | undefined = undefined; let declaracoesPreCarregadas: Declaracao[] = []; let dependenciasArquivos: string[] = []; + let contextoProjetoLiquido: ContextoProjetoLiquido | undefined; + let emContextoLiquido = false; switch (extensaoArquivo) { case "birl": { @@ -108,13 +116,14 @@ export async function executarAnalises( importador.diretorioBase = documento.fileName.substring(0, documento.fileName.lastIndexOf(separador)); const avaliadorComImportacao = new AvaliadorSintaticoComImportacao(importador); - const arquivoDeRotaLiquido = /[\\\/]rotas[\\\/]/i.test(documento.fileName); + contextoProjetoLiquido = await detectorProjetoLiquido(documento.uri); + emContextoLiquido = contextoLiquidoCorrespondeAExtensao(contextoProjetoLiquido, extensaoArquivo); - avaliadorComImportacao.definirContextoLiquido(arquivoDeRotaLiquido); + avaliadorComImportacao.definirContextoLiquido(emContextoLiquido); avaliadorComImportacao.diagnosticos = diagnosticos; - await avaliadorComImportacao.preCarregarDefinicoes(await descobrirDefinicoes(arquivoDeRotaLiquido)); + await avaliadorComImportacao.preCarregarDefinicoes(await descobrirDefinicoes(emContextoLiquido)); - if (arquivoDeRotaLiquido) { + if (emContextoLiquido) { const aliasesContextoLiquido: Record = { Liquido: 'liquido', Requisicao: 'requisicao', @@ -141,11 +150,12 @@ export async function executarAnalises( case "pitu": case "pitugues": lexador = new LexadorPitugues(); - const emRotaLiquidoPitu = /[\\\/]rotas[\\\/]/i.test(documento.fileName); - avaliadorSintatico = emRotaLiquidoPitu + contextoProjetoLiquido = await detectorProjetoLiquido(documento.uri); + emContextoLiquido = contextoLiquidoCorrespondeAExtensao(contextoProjetoLiquido, extensaoArquivo); + avaliadorSintatico = emContextoLiquido ? new AvaliadorSintaticoPituguesLiquido() : new AvaliadorSintaticoPitugues(); - analisadorSemantico = emRotaLiquidoPitu + analisadorSemantico = emContextoLiquido ? new AnalisadorSemanticoPituguesLiquido() : new AnalisadorSemanticoPitugues(); break; @@ -243,10 +253,13 @@ export async function executarAnalises( declaracoesPreCarregadas = Object.values(avaliadorSintatico.tiposDefinidosEmCodigo); (analisadorSemantico as any)?.registrarClassesExternas?.(declaracoesPreCarregadas.filter(d => d instanceof Classe)); - const chaveWorkspace = vscode.workspace.workspaceFolders?.[0]?.uri.toString() || 'sem-workspace'; - const arquivoDeRotaLiquidoFinal = /[\\\/]rotas[\\\/]/i.test(documento.fileName); + const chaveWorkspace = + (vscode.workspace as any).getWorkspaceFolder?.(documento.uri)?.uri?.toString?.() || 'sem-workspace'; + const chaveContexto = contextoProjetoLiquido + ? `${contextoProjetoLiquido.raiz.toString()}::liquido::${contextoProjetoLiquido.arquetipo ?? 'sem-arquetipo'}::${contextoProjetoLiquido.linguagem ?? 'linguagem-invalida'}` + : `${chaveWorkspace}::normal`; definirDefinicoes( - `${chaveWorkspace}::${arquivoDeRotaLiquidoFinal ? 'liquido' : 'normal'}`, + chaveContexto, { ...avaliadorSintatico.tiposDefinidosEmCodigo } ); diff --git a/fontes/extensao-web.ts b/fontes/extensao-web.ts index c3c73af..4d80589 100644 --- a/fontes/extensao-web.ts +++ b/fontes/extensao-web.ts @@ -25,6 +25,8 @@ import { ProvedorTarefasLiquidoWeb } from './tarefas/provedor-tarefas-web'; import { comandosLiquido, TIPO_TAREFA_LIQUIDO } from './tarefas/comandos-liquido'; import { executarAnalises } from './analise-codigo'; +import { ehArquivoConfiguracaoProjetoLiquido } from './liquido/deteccao-projeto-liquido'; +import { reanalisarDocumentosAbertosProjetoLiquido } from './liquido/reanalisar-documentos-projeto-liquido'; import { validarDelprops } from './linguagens/delprops/validador-delprops'; import { DeleguaProvedorAssinaturaMetodos } from './assinaturas-metodos'; import { garantirProvedoresLinguagem } from './ativacao-linguagens'; @@ -242,28 +244,24 @@ export function activate(context: vscode.ExtensionContext) { ); context.subscriptions.push( - vscode.workspace.onDidSaveTextDocument(documento => { + vscode.workspace.onDidSaveTextDocument(async documento => { if (ehArquivoDependenciaProjeto(documento)) { expirarTudo('dependencias-atualizadas'); expirarTodasDefinicoes('dependencias-atualizadas'); } - if (documento.uri.path.endsWith('configuracao.delprops')) { - for (const doc of vscode.workspace.textDocuments) { - if (/[\\\/]rotas[\\\/]/i.test(doc.fileName) && - doc.languageId === 'delegua') { - expirarResultado(doc.uri.toString(), 'delprops-atualizado'); - executarAnalises(doc, diagnosticosDelegua).catch(erro => { - console.error('Erro ao reanalisar rota após atualização de delprops:', erro); - }); - } - } + if (ehArquivoConfiguracaoProjetoLiquido(documento.uri)) { + expirarTodasDefinicoes('delprops-atualizado'); + await reanalisarDocumentosAbertosProjetoLiquido(diagnosticosDelegua, { + arquivosConfiguracao: [documento.uri], + motivo: 'delprops-atualizado', + }); } }) ); context.subscriptions.push( - vscode.workspace.onDidCreateFiles(evento => { + vscode.workspace.onDidCreateFiles(async evento => { const uris = evento.files.map(arquivo => arquivo.toString()); expirarResultados(uris, 'arquivo-criado'); @@ -272,11 +270,20 @@ export function activate(context: vscode.ExtensionContext) { .map(arquivo => arquivo.path); expirarResultadosPorDependenciaArquivo(caminhosAfetados, 'arquivo-linguagem-criado'); + + const configuracoesLiquido = evento.files.filter(ehArquivoConfiguracaoProjetoLiquido); + if (configuracoesLiquido.length > 0) { + expirarTodasDefinicoes('configuracao-liquido-criada'); + await reanalisarDocumentosAbertosProjetoLiquido(diagnosticosDelegua, { + arquivosConfiguracao: configuracoesLiquido, + motivo: 'configuracao-liquido-criada', + }); + } }) ); context.subscriptions.push( - vscode.workspace.onDidDeleteFiles(evento => { + vscode.workspace.onDidDeleteFiles(async evento => { const uris = evento.files.map(arquivo => arquivo.toString()); expirarResultados(uris, 'arquivo-removido'); @@ -285,11 +292,21 @@ export function activate(context: vscode.ExtensionContext) { .map(arquivo => arquivo.path); expirarResultadosPorDependenciaArquivo(caminhosAfetados, 'arquivo-linguagem-removido'); + + const configuracoesLiquido = evento.files.filter(ehArquivoConfiguracaoProjetoLiquido); + if (configuracoesLiquido.length > 0) { + expirarTodasDefinicoes('configuracao-liquido-removida'); + await reanalisarDocumentosAbertosProjetoLiquido(diagnosticosDelegua, { + arquivosConfiguracao: configuracoesLiquido, + incluirDocumentosSemProjeto: true, + motivo: 'configuracao-liquido-removida', + }); + } }) ); context.subscriptions.push( - vscode.workspace.onDidRenameFiles(evento => { + vscode.workspace.onDidRenameFiles(async evento => { expirarResultados( evento.files.flatMap(arquivo => [arquivo.oldUri.toString(), arquivo.newUri.toString()]), 'arquivo-renomeado' @@ -301,6 +318,18 @@ export function activate(context: vscode.ExtensionContext) { .flatMap(arquivo => [arquivo.oldUri.path, arquivo.newUri.path]), 'arquivo-linguagem-renomeado' ); + + const configuracoesLiquido = evento.files + .flatMap(arquivo => [arquivo.oldUri, arquivo.newUri]) + .filter(ehArquivoConfiguracaoProjetoLiquido); + if (configuracoesLiquido.length > 0) { + expirarTodasDefinicoes('configuracao-liquido-renomeada'); + await reanalisarDocumentosAbertosProjetoLiquido(diagnosticosDelegua, { + arquivosConfiguracao: configuracoesLiquido, + incluirDocumentosSemProjeto: true, + motivo: 'configuracao-liquido-renomeada', + }); + } }) ); diff --git a/fontes/extensao.ts b/fontes/extensao.ts index 5c58809..ba7976f 100644 --- a/fontes/extensao.ts +++ b/fontes/extensao.ts @@ -24,6 +24,8 @@ import { DeleguaProvedorFormatacao } from './formatadores/delegua-provedor-forma import { traduzir } from './traducao'; import { executarAnalises } from './analise-codigo'; +import { ehArquivoConfiguracaoProjetoLiquido } from './liquido/deteccao-projeto-liquido'; +import { reanalisarDocumentosAbertosProjetoLiquido } from './liquido/reanalisar-documentos-projeto-liquido'; import { DeleguaProvedorAssinaturaMetodos, DeleguaTestesProvedorAssinaturaMetodos } from './assinaturas-metodos'; import { garantirProvedoresLinguagem } from './ativacao-linguagens'; @@ -223,28 +225,24 @@ export function activate(context: vscode.ExtensionContext) { ); context.subscriptions.push( - vscode.workspace.onDidSaveTextDocument(documento => { + vscode.workspace.onDidSaveTextDocument(async documento => { if (ehArquivoDependenciaProjeto(documento)) { expirarTudo('dependencias-atualizadas'); expirarTodasDefinicoes('dependencias-atualizadas'); } - if (documento.uri.path.endsWith('configuracao.delprops')) { - for (const doc of vscode.workspace.textDocuments) { - if (/[\\\/]rotas[\\\/]/i.test(doc.fileName) && - doc.languageId === 'delegua') { - expirarResultado(doc.uri.toString(), 'delprops-atualizado'); - executarAnalises(doc, diagnosticosDelegua).catch(erro => { - console.error('Erro ao reanalisar rota após atualização de delprops:', erro); - }); - } - } + if (ehArquivoConfiguracaoProjetoLiquido(documento.uri)) { + expirarTodasDefinicoes('delprops-atualizado'); + await reanalisarDocumentosAbertosProjetoLiquido(diagnosticosDelegua, { + arquivosConfiguracao: [documento.uri], + motivo: 'delprops-atualizado', + }); } }) ); context.subscriptions.push( - vscode.workspace.onDidCreateFiles(evento => { + vscode.workspace.onDidCreateFiles(async evento => { const uris = evento.files.map(arquivo => arquivo.toString()); expirarResultados(uris, 'arquivo-criado'); @@ -253,11 +251,20 @@ export function activate(context: vscode.ExtensionContext) { .map(arquivo => arquivo.fsPath); expirarResultadosPorDependenciaArquivo(caminhosAfetados, 'arquivo-delegua-criado'); + + const configuracoesLiquido = evento.files.filter(ehArquivoConfiguracaoProjetoLiquido); + if (configuracoesLiquido.length > 0) { + expirarTodasDefinicoes('configuracao-liquido-criada'); + await reanalisarDocumentosAbertosProjetoLiquido(diagnosticosDelegua, { + arquivosConfiguracao: configuracoesLiquido, + motivo: 'configuracao-liquido-criada', + }); + } }) ); context.subscriptions.push( - vscode.workspace.onDidDeleteFiles(evento => { + vscode.workspace.onDidDeleteFiles(async evento => { const uris = evento.files.map(arquivo => arquivo.toString()); expirarResultados(uris, 'arquivo-removido'); @@ -266,6 +273,16 @@ export function activate(context: vscode.ExtensionContext) { .map(arquivo => arquivo.fsPath); expirarResultadosPorDependenciaArquivo(caminhosAfetados, 'arquivo-delegua-removido'); + + const configuracoesLiquido = evento.files.filter(ehArquivoConfiguracaoProjetoLiquido); + if (configuracoesLiquido.length > 0) { + expirarTodasDefinicoes('configuracao-liquido-removida'); + await reanalisarDocumentosAbertosProjetoLiquido(diagnosticosDelegua, { + arquivosConfiguracao: configuracoesLiquido, + incluirDocumentosSemProjeto: true, + motivo: 'configuracao-liquido-removida', + }); + } }) ); @@ -717,6 +734,18 @@ export function activate(context: vscode.ExtensionContext) { 'arquivo-delegua-renomeado' ); + const configuracoesLiquido = evento.files + .flatMap(arquivo => [arquivo.oldUri, arquivo.newUri]) + .filter(ehArquivoConfiguracaoProjetoLiquido); + if (configuracoesLiquido.length > 0) { + expirarTodasDefinicoes('configuracao-liquido-renomeada'); + await reanalisarDocumentosAbertosProjetoLiquido(diagnosticosDelegua, { + arquivosConfiguracao: configuracoesLiquido, + incluirDocumentosSemProjeto: true, + motivo: 'configuracao-liquido-renomeada', + }); + } + const houveRenomeacaoDelgua = evento.files.some(arquivo => ehArquivoDelegua(arquivo.oldUri) || ehArquivoDelegua(arquivo.newUri) ); diff --git a/fontes/liquido/configuracao-projeto-liquido.ts b/fontes/liquido/configuracao-projeto-liquido.ts new file mode 100644 index 0000000..10aebd7 --- /dev/null +++ b/fontes/liquido/configuracao-projeto-liquido.ts @@ -0,0 +1,178 @@ +export const NOME_ARQUIVO_CONFIGURACAO_LIQUIDO = 'configuracao.delprops'; + +export type LinguagemProjetoLiquido = 'delegua' | 'pitugues'; + +export interface ConfiguracaoProjetoLiquido { + arquetipo?: string; + linguagem?: LinguagemProjetoLiquido; +} + +export interface ContextoProjetoLiquidoPorCaminho extends ConfiguracaoProjetoLiquido { + raiz: string; + caminhoConfiguracao: string; +} + +function removerComentarioLinha(linha: string): string { + let emAspasSimples = false; + let emAspasDuplas = false; + + for (let indice = 0; indice < linha.length - 1; indice++) { + const caractere = linha[indice]; + + if (caractere === '\\') { + indice++; + continue; + } + + if (caractere === "'" && !emAspasDuplas) { + emAspasSimples = !emAspasSimples; + continue; + } + + if (caractere === '"' && !emAspasSimples) { + emAspasDuplas = !emAspasDuplas; + continue; + } + + if (caractere === '/' && linha[indice + 1] === '/' && !emAspasSimples && !emAspasDuplas) { + return linha.slice(0, indice); + } + } + + return linha; +} + +function normalizarTexto(valor: string): string { + return valor + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .trim() + .toLowerCase(); +} + +function normalizarLinguagem(valor: string): LinguagemProjetoLiquido | undefined { + const linguagem = normalizarTexto(valor); + + if (linguagem === 'delegua') { + return 'delegua'; + } + + if (linguagem === 'pitugues') { + return 'pitugues'; + } + + return undefined; +} + +/** + * Extrai apenas as propriedades necessárias para contextualizar a extensão. + * A validação completa continua sendo responsabilidade do validador Delprops. + */ +export function extrairConfiguracaoProjetoLiquido(conteudo: string): ConfiguracaoProjetoLiquido { + let arquetipo: string | undefined; + let linguagem: LinguagemProjetoLiquido | undefined = 'delegua'; + const padraoPropriedade = /^\s*liquido\.(arquetipo|linguagem)\s*=\s*(['"])(.*?)\2\s*;?\s*$/i; + + for (const linhaOriginal of conteudo.replace(/^\uFEFF/, '').split(/\r?\n/)) { + const linha = removerComentarioLinha(linhaOriginal); + const correspondencia = padraoPropriedade.exec(linha); + + if (!correspondencia) { + continue; + } + + const propriedade = normalizarTexto(correspondencia[1]); + const valor = correspondencia[3]; + + if (propriedade === 'arquetipo') { + arquetipo = normalizarTexto(valor); + } else { + linguagem = normalizarLinguagem(valor); + } + } + + return { arquetipo, linguagem }; +} + +function removerBarrasFinais(caminho: string): string { + if (caminho === '/') { + return caminho; + } + + return caminho.replace(/\/+$/, ''); +} + +function obterDiretorio(caminhoArquivo: string): string { + const caminho = removerBarrasFinais(caminhoArquivo); + const ultimoSeparador = caminho.lastIndexOf('/'); + + if (ultimoSeparador <= 0) { + return '/'; + } + + return caminho.slice(0, ultimoSeparador); +} + +function caminhoEstaNaRaiz(caminho: string, raiz: string): boolean { + return raiz === '/' || caminho === raiz || caminho.startsWith(`${raiz}/`); +} + +/** + * Retorna os diretórios a consultar do mais próximo do arquivo até a raiz do workspace. + * Assim, um monorepo pode conter vários projetos Líquido independentes. + */ +export function obterDiretoriosCandidatosConfiguracao( + caminhoArquivo: string, + caminhoRaizWorkspace: string +): string[] { + const raiz = removerBarrasFinais(caminhoRaizWorkspace) || '/'; + let diretorioAtual = obterDiretorio(caminhoArquivo); + + if (!caminhoEstaNaRaiz(diretorioAtual, raiz)) { + return []; + } + + const candidatos: string[] = []; + + while (caminhoEstaNaRaiz(diretorioAtual, raiz)) { + candidatos.push(diretorioAtual); + + if (diretorioAtual === raiz) { + break; + } + + const diretorioPai = obterDiretorio(diretorioAtual); + if (diretorioPai === diretorioAtual) { + break; + } + + diretorioAtual = diretorioPai; + } + + return candidatos; +} + +export async function localizarConfiguracaoProjetoLiquido( + caminhoArquivo: string, + caminhoRaizWorkspace: string, + lerArquivo: (caminho: string) => Promise +): Promise { + const diretorios = obterDiretoriosCandidatosConfiguracao(caminhoArquivo, caminhoRaizWorkspace); + + for (const raiz of diretorios) { + const caminhoConfiguracao = `${raiz === '/' ? '' : raiz}/${NOME_ARQUIVO_CONFIGURACAO_LIQUIDO}`; + const conteudo = await lerArquivo(caminhoConfiguracao); + + if (conteudo === undefined) { + continue; + } + + return { + raiz, + caminhoConfiguracao, + ...extrairConfiguracaoProjetoLiquido(conteudo), + }; + } + + return undefined; +} diff --git a/fontes/liquido/deteccao-projeto-liquido.ts b/fontes/liquido/deteccao-projeto-liquido.ts new file mode 100644 index 0000000..e6a50e7 --- /dev/null +++ b/fontes/liquido/deteccao-projeto-liquido.ts @@ -0,0 +1,196 @@ +import * as vscode from 'vscode'; + +import type { + ContextoProjetoLiquidoPorCaminho, + LinguagemProjetoLiquido, +} from './configuracao-projeto-liquido'; +import { + NOME_ARQUIVO_CONFIGURACAO_LIQUIDO, + localizarConfiguracaoProjetoLiquido, + obterDiretoriosCandidatosConfiguracao, +} from './configuracao-projeto-liquido'; + +export interface ContextoProjetoLiquido { + raiz: vscode.Uri; + arquivoConfiguracao: vscode.Uri; + arquetipo?: string; + linguagem?: LinguagemProjetoLiquido; +} + +const cacheDeteccao = new Map>(); +const decodificadorUtf8 = new TextDecoder('utf-8'); + +function obterCaminhoUriSeguro(uri: vscode.Uri | undefined): string | undefined { + if (!uri) { + return undefined; + } + + const caminhoDireto = (uri as any).path; + if (typeof caminhoDireto === 'string' && caminhoDireto.length > 0) { + return caminhoDireto; + } + + const caminhoSistema = (uri as any).fsPath; + if (typeof caminhoSistema === 'string' && caminhoSistema.length > 0) { + return caminhoSistema.replace(/\\/g, '/'); + } + + try { + const valor = uri.toString?.(); + if (typeof valor === 'string' && valor.length > 0) { + if (valor.startsWith('file://')) { + return decodeURIComponent(new URL(valor).pathname); + } + + const indiceEsquema = valor.indexOf('://'); + if (indiceEsquema >= 0) { + const indiceCaminho = valor.indexOf('/', indiceEsquema + 3); + return indiceCaminho >= 0 ? valor.slice(indiceCaminho) : '/'; + } + + return valor.startsWith('/') ? valor : undefined; + } + } catch { + return undefined; + } + + return undefined; +} + +function caminhoEstaSobRaiz(caminho: string, raiz: string): boolean { + return raiz === '/' || caminho === raiz || caminho.startsWith(`${raiz}/`); +} + +function obterPastaTrabalhoSegura( + uriArquivo: vscode.Uri, + caminhoArquivo: string +): vscode.WorkspaceFolder | undefined { + const obterPasta = (vscode.workspace as any)?.getWorkspaceFolder; + if (typeof obterPasta === 'function') { + return obterPasta.call(vscode.workspace, uriArquivo); + } + + const pastas = (vscode.workspace as any)?.workspaceFolders; + if (!Array.isArray(pastas)) { + return undefined; + } + + return pastas.find((pasta: vscode.WorkspaceFolder) => { + const raiz = obterCaminhoUriSeguro(pasta?.uri); + return raiz ? caminhoEstaSobRaiz(caminhoArquivo, raiz) : false; + }); +} + +function uriNoMesmoProvedor(uriBase: vscode.Uri, caminho: string): vscode.Uri { + return uriBase.with({ path: caminho, query: '', fragment: '' }); +} + +function converterContexto( + contexto: ContextoProjetoLiquidoPorCaminho, + uriBase: vscode.Uri +): ContextoProjetoLiquido { + return { + raiz: uriNoMesmoProvedor(uriBase, contexto.raiz), + arquivoConfiguracao: uriNoMesmoProvedor(uriBase, contexto.caminhoConfiguracao), + arquetipo: contexto.arquetipo, + linguagem: contexto.linguagem, + }; +} + +async function detectarSemCache( + uriArquivo: vscode.Uri, + pastaTrabalho: vscode.WorkspaceFolder, + caminhoArquivo: string, + caminhoRaizWorkspace: string +): Promise { + const leitor = (vscode.workspace as any)?.fs?.readFile; + if (typeof leitor !== 'function') { + return undefined; + } + + const contexto = await localizarConfiguracaoProjetoLiquido( + caminhoArquivo, + caminhoRaizWorkspace, + async caminhoConfiguracao => { + try { + const conteudo = await leitor.call( + (vscode.workspace as any).fs, + uriNoMesmoProvedor(pastaTrabalho.uri, caminhoConfiguracao) + ); + return decodificadorUtf8.decode(conteudo); + } catch { + return undefined; + } + } + ); + + return contexto ? converterContexto(contexto, pastaTrabalho.uri) : undefined; +} + +export async function detectarProjetoLiquido( + uriArquivo: vscode.Uri +): Promise { + const caminhoArquivo = obterCaminhoUriSeguro(uriArquivo); + if (!caminhoArquivo) { + return undefined; + } + + const pastaTrabalho = obterPastaTrabalhoSegura(uriArquivo, caminhoArquivo); + const caminhoRaizWorkspace = obterCaminhoUriSeguro(pastaTrabalho?.uri); + + if (!pastaTrabalho || !caminhoRaizWorkspace) { + return undefined; + } + + const diretorioDocumento = obterDiretoriosCandidatosConfiguracao( + caminhoArquivo, + caminhoRaizWorkspace + )[0] ?? caminhoArquivo; + const chaveCache = `${pastaTrabalho.uri.toString()}::${diretorioDocumento}`; + + let deteccao = cacheDeteccao.get(chaveCache); + if (!deteccao) { + deteccao = detectarSemCache( + uriArquivo, + pastaTrabalho, + caminhoArquivo, + caminhoRaizWorkspace + ); + cacheDeteccao.set(chaveCache, deteccao); + } + + return deteccao; +} + +export function invalidarCacheDeteccaoProjetoLiquido(): void { + cacheDeteccao.clear(); +} + +export function ehArquivoConfiguracaoProjetoLiquido(uri: vscode.Uri): boolean { + const caminho = obterCaminhoUriSeguro(uri); + if (!caminho) { + return false; + } + + const nomeArquivo = caminho.split('/').pop()?.toLowerCase(); + return nomeArquivo === NOME_ARQUIVO_CONFIGURACAO_LIQUIDO; +} + +export function contextoLiquidoCorrespondeAExtensao( + contexto: ContextoProjetoLiquido | undefined, + extensaoArquivo: string +): boolean { + if (!contexto?.linguagem) { + return false; + } + + if (extensaoArquivo === 'delegua') { + return contexto.linguagem === 'delegua'; + } + + if (extensaoArquivo === 'pitu' || extensaoArquivo === 'pitugues') { + return contexto.linguagem === 'pitugues'; + } + + return false; +} diff --git a/fontes/liquido/reanalisar-documentos-projeto-liquido.ts b/fontes/liquido/reanalisar-documentos-projeto-liquido.ts new file mode 100644 index 0000000..a4f8265 --- /dev/null +++ b/fontes/liquido/reanalisar-documentos-projeto-liquido.ts @@ -0,0 +1,77 @@ +import * as vscode from 'vscode'; + +import { expirarResultado } from '@designliquido/delegua-lsp/analise/cache-analise'; + +import { executarAnalises } from '../analise-codigo'; +import { + detectarProjetoLiquido, + invalidarCacheDeteccaoProjetoLiquido, +} from './deteccao-projeto-liquido'; + +export interface OpcoesReanaliseProjetoLiquido { + arquivosConfiguracao?: vscode.Uri[]; + incluirDocumentosSemProjeto?: boolean; + motivo?: string; +} + +const linguagensAnalisaveis = new Set(['delegua', 'delegua-testes', 'pitugues']); + +function obterDiretorioUri(uri: vscode.Uri): vscode.Uri { + const indiceUltimaBarra = uri.path.lastIndexOf('/'); + const caminho = indiceUltimaBarra <= 0 ? '/' : uri.path.slice(0, indiceUltimaBarra); + return uri.with({ path: caminho, query: '', fragment: '' }); +} + +function uriEstaSobRaiz(uri: vscode.Uri, raiz: vscode.Uri): boolean { + if (uri.scheme !== raiz.scheme || uri.authority !== raiz.authority) { + return false; + } + + return raiz.path === '/' || uri.path === raiz.path || uri.path.startsWith(`${raiz.path}/`); +} + +export async function reanalisarDocumentosAbertosProjetoLiquido( + diagnosticos: vscode.DiagnosticCollection, + opcoes: OpcoesReanaliseProjetoLiquido = {} +): Promise { + invalidarCacheDeteccaoProjetoLiquido(); + + const arquivosConfiguracao = opcoes.arquivosConfiguracao ?? []; + const configuracoesEsperadas = new Set(arquivosConfiguracao.map(uri => uri.toString())); + const raizesAfetadas = arquivosConfiguracao.map(obterDiretorioUri); + const motivo = opcoes.motivo ?? 'contexto-liquido-atualizado'; + + for (const documento of vscode.workspace.textDocuments) { + if (!linguagensAnalisaveis.has(documento.languageId)) { + continue; + } + + if (raizesAfetadas.length > 0 && !raizesAfetadas.some(raiz => uriEstaSobRaiz(documento.uri, raiz))) { + continue; + } + + const contexto = await detectarProjetoLiquido(documento.uri); + + if (contexto) { + if ( + configuracoesEsperadas.size > 0 && + !configuracoesEsperadas.has(contexto.arquivoConfiguracao.toString()) + ) { + continue; + } + } else if (!opcoes.incluirDocumentosSemProjeto) { + continue; + } + + expirarResultado(documento.uri.toString(), motivo); + + try { + await executarAnalises(documento, diagnosticos); + } catch (erro) { + console.error( + `Erro ao reanalisar documento após alteração do contexto Líquido: ${documento.uri.toString()}`, + erro + ); + } + } +} diff --git a/testes/analise-codigo/index.test.ts b/testes/analise-codigo/index.test.ts index deaacb0..e53f2c3 100644 --- a/testes/analise-codigo/index.test.ts +++ b/testes/analise-codigo/index.test.ts @@ -223,6 +223,32 @@ jest.mock('../../fontes/avaliacao-sintatica/avaliador-sintatico-com-importacao', })) })); + +function criarUriLiquidoTeste(caminho: string): any { + return { + scheme: 'file', + authority: '', + path: caminho, + fsPath: caminho, + query: '', + fragment: '', + toString: () => `file://${caminho}`, + with: (alteracoes: any) => + criarUriLiquidoTeste(alteracoes?.path ?? caminho), + }; +} + +function criarContextoLiquidoTeste(): any { + return { + raiz: criarUriLiquidoTeste('/workspace'), + arquivoConfiguracao: criarUriLiquidoTeste( + '/workspace/configuracao.delprops' + ), + arquetipo: 'mvc', + linguagem: 'delegua', + }; +} + describe('analise-codigo/index', () => { let executarAnalises: any; let mockDocumento: any; @@ -494,12 +520,12 @@ describe('analise-codigo/index', () => { return resultado.declaracoesPreCarregadas; } - it('arquivo em /rotas/ com classe Liquido → alias liquido adicionado', async () => { + it('arquivo em projeto com configuracao.delprops fora de /rotas/ com classe Liquido → alias liquido adicionado', async () => { const mockLiquidoClasse = { simbolo: { lexema: 'Liquido' } }; configurarAvaliadorComTipos({ Liquido: mockLiquidoClasse }); - mockDocumento.fileName = '/workspace/rotas/inicial.delegua'; - await executarAnalises(mockDocumento, mockDiagnosticos); + mockDocumento.fileName = '/workspace/modelos/inicial.delegua'; + await executarAnalises(mockDocumento, mockDiagnosticos, async () => criarContextoLiquidoTeste()); const declaracoes = obterDeclaracoesPreCarregadas(); // Liquido (original) + liquido (alias) = 2 entradas @@ -507,13 +533,13 @@ describe('analise-codigo/index', () => { expect(declaracoes).toContain(mockLiquidoClasse); }); - it('arquivo em /rotas/ com Requisicao e Resposta → aliases adicionados', async () => { + it('arquivo em projeto com configuracao.delprops fora de /rotas/ com Requisicao e Resposta → aliases adicionados', async () => { const mockRequisicao = { simbolo: { lexema: 'Requisicao' } }; const mockResposta = { simbolo: { lexema: 'Resposta' } }; configurarAvaliadorComTipos({ Requisicao: mockRequisicao, Resposta: mockResposta }); - mockDocumento.fileName = '/workspace/rotas/inicial.delegua'; - await executarAnalises(mockDocumento, mockDiagnosticos); + mockDocumento.fileName = '/workspace/modelos/inicial.delegua'; + await executarAnalises(mockDocumento, mockDiagnosticos, async () => criarContextoLiquidoTeste()); const declaracoes = obterDeclaracoesPreCarregadas(); // 2 originais (Requisicao, Resposta) + 2 aliases (requisicao, resposta) = 4 @@ -522,7 +548,7 @@ describe('analise-codigo/index', () => { expect(declaracoes).toContain(mockResposta); }); - it('arquivo em /rotas/ com todas as classes de contexto → todos os aliases adicionados', async () => { + it('arquivo em projeto com configuracao.delprops fora de /rotas/ com todas as classes de contexto → todos os aliases adicionados', async () => { const mockLiquido = { simbolo: { lexema: 'Liquido' } }; const mockRequisicao = { simbolo: { lexema: 'Requisicao' } }; const mockResposta = { simbolo: { lexema: 'Resposta' } }; @@ -532,20 +558,20 @@ describe('analise-codigo/index', () => { Resposta: mockResposta, }); - mockDocumento.fileName = '/workspace/rotas/inicial.delegua'; - await executarAnalises(mockDocumento, mockDiagnosticos); + mockDocumento.fileName = '/workspace/modelos/inicial.delegua'; + await executarAnalises(mockDocumento, mockDiagnosticos, async () => criarContextoLiquidoTeste()); const declaracoes = obterDeclaracoesPreCarregadas(); // 3 originais + 3 aliases = 6 expect(declaracoes).toHaveLength(6); }); - it('arquivo fora de /rotas/ → nenhum alias adicionado', async () => { + it('arquivo em /rotas/ sem configuracao.delprops → nenhum alias adicionado', async () => { const mockLiquidoClasse = { simbolo: { lexema: 'Liquido' } }; configurarAvaliadorComTipos({ Liquido: mockLiquidoClasse }); - mockDocumento.fileName = '/workspace/controladores/inicio.delegua'; - await executarAnalises(mockDocumento, mockDiagnosticos); + mockDocumento.fileName = '/workspace/rotas/inicial.delegua'; + await executarAnalises(mockDocumento, mockDiagnosticos, async () => undefined); const declaracoes = obterDeclaracoesPreCarregadas(); // Apenas o original — sem alias @@ -553,22 +579,22 @@ describe('analise-codigo/index', () => { expect(declaracoes[0]).toBe(mockLiquidoClasse); }); - it('arquivo em /rotas/ sem classes de contexto em tiposDefinidosEmCodigo → sem erro', async () => { + it('arquivo em projeto com configuracao.delprops sem classes de contexto em tiposDefinidosEmCodigo → sem erro', async () => { configurarAvaliadorComTipos({}); - mockDocumento.fileName = '/workspace/rotas/inicial.delegua'; - await expect(executarAnalises(mockDocumento, mockDiagnosticos)).resolves.not.toThrow(); + mockDocumento.fileName = '/workspace/modelos/inicial.delegua'; + await expect(executarAnalises(mockDocumento, mockDiagnosticos, async () => criarContextoLiquidoTeste())).resolves.not.toThrow(); const declaracoes = obterDeclaracoesPreCarregadas(); expect(declaracoes).toHaveLength(0); }); - it('alias tem simbolo.lexema com nome minúsculo e é objeto distinto do original', async () => { + it('alias em projeto detectado tem simbolo.lexema minúsculo e é distinto do original', async () => { const mockLiquidoClasse = { simbolo: { lexema: 'Liquido' } }; configurarAvaliadorComTipos({ Liquido: mockLiquidoClasse }); - mockDocumento.fileName = '/workspace/rotas/inicial.delegua'; - await executarAnalises(mockDocumento, mockDiagnosticos); + mockDocumento.fileName = '/workspace/modelos/inicial.delegua'; + await executarAnalises(mockDocumento, mockDiagnosticos, async () => criarContextoLiquidoTeste()); const declaracoes = obterDeclaracoesPreCarregadas(); const alias = declaracoes.find(d => d !== mockLiquidoClasse); diff --git a/testes/liquido/configuracao-projeto-liquido.test.ts b/testes/liquido/configuracao-projeto-liquido.test.ts new file mode 100644 index 0000000..b1b9428 --- /dev/null +++ b/testes/liquido/configuracao-projeto-liquido.test.ts @@ -0,0 +1,99 @@ +import { + extrairConfiguracaoProjetoLiquido, + localizarConfiguracaoProjetoLiquido, + obterDiretoriosCandidatosConfiguracao, +} from '../../fontes/liquido/configuracao-projeto-liquido'; + +describe('configuração de projeto Líquido', () => { + it('lê arquétipo e linguagem Delégua, ignorando comentários', () => { + const configuracao = extrairConfiguracaoProjetoLiquido(` + liquido.arquetipo = 'rest' + liquido.linguagem = "delégua" // comentário + liquido.roteador.origensCors = 'https://exemplo.com.br' + `); + + expect(configuracao).toEqual({ + arquetipo: 'rest', + linguagem: 'delegua', + }); + }); + + it('normaliza Pituguês e preserva o arquétipo MVC', () => { + const configuracao = extrairConfiguracaoProjetoLiquido(` + liquido.arquetipo = 'MVC'; + liquido.linguagem = 'pituguês'; + `); + + expect(configuracao).toEqual({ + arquetipo: 'mvc', + linguagem: 'pitugues', + }); + }); + + it('usa Delégua como linguagem padrão quando a propriedade não existe', () => { + expect(extrairConfiguracaoProjetoLiquido("liquido.arquetipo = 'rest'")) + .toEqual({ arquetipo: 'rest', linguagem: 'delegua' }); + }); + + it('procura do diretório do arquivo até a raiz do workspace', () => { + expect(obterDiretoriosCandidatosConfiguracao( + '/workspace/aplicacao/modelos/usuario.delegua', + '/workspace' + )).toEqual([ + '/workspace/aplicacao/modelos', + '/workspace/aplicacao', + '/workspace', + ]); + }); + + it('detecta um projeto pelo marcador para arquivos fora de rotas', async () => { + const arquivos = new Map([ + [ + '/workspace/configuracao.delprops', + "liquido.arquetipo = 'mvc'\nliquido.linguagem = 'delegua'", + ], + ]); + + const contexto = await localizarConfiguracaoProjetoLiquido( + '/workspace/modelos/usuario.delegua', + '/workspace', + async caminho => arquivos.get(caminho) + ); + + expect(contexto).toEqual({ + raiz: '/workspace', + caminhoConfiguracao: '/workspace/configuracao.delprops', + arquetipo: 'mvc', + linguagem: 'delegua', + }); + }); + + it('não produz falso positivo para uma pasta rotas sem configuracao.delprops', async () => { + const contexto = await localizarConfiguracaoProjetoLiquido( + '/workspace/rotas/inicial.delegua', + '/workspace', + async () => undefined + ); + + expect(contexto).toBeUndefined(); + }); + + it('prioriza o projeto aninhado mais próximo em monorepos', async () => { + const arquivos = new Map([ + ['/workspace/configuracao.delprops', "liquido.linguagem = 'delegua'"], + [ + '/workspace/apps/portal/configuracao.delprops', + "liquido.arquetipo = 'mvc'\nliquido.linguagem = 'pituguês'", + ], + ]); + + const contexto = await localizarConfiguracaoProjetoLiquido( + '/workspace/apps/portal/visoes/inicial.pitu', + '/workspace', + async caminho => arquivos.get(caminho) + ); + + expect(contexto?.raiz).toBe('/workspace/apps/portal'); + expect(contexto?.linguagem).toBe('pitugues'); + }); +});