diff --git a/CHANGELOG.md b/CHANGELOG.md index 0eadc63..419ed3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,23 @@ caso encontre por favor relate para ajudar a manter a consistência dos dados. marcação continua virando a descrição, como antes - **Tags de documentação no autocomplete** — o trigger `@`, dentro de um comentário, passa a oferecer `@param`, `@return` e `@remarks` com snippets +- **Padrão próprio no estilo de nomenclatura** — um item da lista de estilos + aceitos escrito entre barras (`/^g_[a-z][a-zA-Z0-9]*$/`) passa a ser lido como + expressão regular, para convenções que os cinco estilos embutidos não descrevem + — prefixo de global, notação húngara. Convive com eles pela regra que já valia: + o nome é aceito se casar com **qualquer** critério da categoria. + + O padrão é âncorado como `^(?:…)$` — descreve o nome inteiro, e o agrupamento + impede que uma alternância no topo ancore só os extremos. O `_` inicial **não** + é removido antes da comparação, ao contrário dos estilos embutidos: quem + escreve o padrão decide se o aceita. Um padrão inválido é ignorado sem derrubar + a análise nem os demais critérios da categoria. + + Não gera sugestão de renomeação: de um regex arbitrário dá para saber se o nome + passa, não como reescrevê-lo. Por isso o critério é um tipo próprio (`Rule`) em + vez de uma variante de `Case`, que alimenta o gerador de nomes. A crate `regex` + tem tempo de execução linear garantido, então um padrão custoso não degrada a + análise - **Diagnósticos e hovers traduzidos para Espanhol, Romeno e Russo** — as tabelas de mensagens `messages/langs/{es,ro,ru}.rs` existiam como esqueleto (texto ainda em inglês, copiado de `en.rs`) e agora estão de fato traduzidas: as 75 mensagens @@ -80,7 +97,69 @@ caso encontre por favor relate para ajudar a manter a consistência dos dados. por ecossistema (cargo, GitHub Actions, pip) em vez de abrir um PR por dependência, reduzindo o ruído de manutenção +- **Autocomplete ordenado pela proximidade do cursor** — a lista vinha ordenada + de um jeito que punha as variáveis locais e os parâmetros **abaixo** de + milhares de nativas dos includes; o que está mais perto de quem escreve + aparecia por último. A ordem passa a ser: locais e parâmetros, símbolos do + próprio arquivo, símbolos dos includes, palavras-chave e, por fim, os + marcados com `#pragma deprecated` — que continuam aparecendo (às vezes é + mesmo o que se quer), mas nunca à frente de uma alternativa viva. Dentro de + cada grupo a ordem é alfabética sem diferenciar maiúsculas +- **Listas grandes de completion não travam mais a digitação** — um projeto com + muitos includes mandava todos os símbolos ao editor a cada tecla. A resposta + passa a ser cortada em 1000 itens e marcada como `isIncomplete`, fazendo o + editor pedir de novo conforme o prefixo cresce. O corte vem depois da + ordenação, então o que se perde são os itens mais distantes do cursor +- **Correções rápidas para mais nove diagnósticos** — passam a ter *quick fix*: + remover o corpo `{ }` ilegal de um `native`/`forward` (`PP0002`/`PP0003`); + dar corpo vazio ou converter em `forward` quando falta o corpo (`PP0004`); + trocar por um símbolo de nome parecido quando a função chamada não existe + (`PP0010`, o caso comum de erro de digitação); remover o `#define` e o + `#include` não utilizados (`PP0011`/`PP0012`); e reindentar a linha + (`PP0017`), usando o estilo de formatação configurado no projeto, não uma + indentação fixa. Somados aos que já existiam, catorze dos dezenove + diagnósticos agora oferecem correção +- **`PP0019` — `#pragma` desconhecido ou malformado** — o compilador rejeita uma + diretiva que não conhece (erro 207), mas só na compilação; agora o aviso + aparece enquanto se escreve, com *quick fix*. Cobre o nome errado + (`#pragma deprected` → sugere `deprecated`, comparando com a lista do + compilador) e a mensagem de `deprecated` escrita entre aspas — a diretiva toma + o resto da linha como texto livre, então as aspas entrariam na mensagem em vez + de delimitá-la. Aspas no meio do texto continuam sendo texto legítimo + ### Corrigido +- **Comentário de outra parte do arquivo aparecendo no hover** — a varredura do + doc comment subia o arquivo acumulando linhas, e acabava trazendo réguas, + cabeçalhos de seção e o texto de outras funções para o hover do símbolo + abaixo. Passa a valer a mesma regra do Javadoc e do PHPDoc: **só o bloco + imediatamente acima da declaração** — um `/* … */` colado nela, ou uma + sequência contígua de linhas `//`. Uma linha em branco, ou qualquer código + entre os dois, separa. Entre o comentário e a declaração continua podendo + haver o `#pragma deprecated` que marca o símbolo +- **Doc comment de um símbolo aparecendo no hover de outro** — quando o + comentário era um bloco de uma linha só (`/** … */`), a varredura empurrava + essa linha e ia procurar o `/*` de abertura **a partir da linha anterior**, + atravessando o código acima até casar com o `/**` de outro comentário. O + hover de um `#define`, por exemplo, mostrava a documentação da função + anterior com o código do meio junto. Um `*/` sem abertura também deixa de + virar doc, em vez de arrastar o arquivo até o topo +- **Aviso de depreciado desalinhando o hover** — era escrito como *blockquote* + (`>`), o que fazia o editor recuar o bloco e ler a linha `---` seguinte como + continuação dele, desalinhando a documentação inteira. Passa a ser texto + normal — e agora traz junto a mensagem do `#pragma deprecated`, que antes só + aparecia no aviso de uso +- **`native`/`forward` com assinatura quebrada em várias linhas eram ignorados** — + ao fechar o `)`, o parser só criava o símbolo se a linha trouxesse `{`; sem + isso, ficava esperando um corpo que nunca chega, já que essas duas formas + declaram sem corpo e terminam em `;`. O símbolo se perdia: sem hover, sem + autocomplete e sem signature help. Atinge em cheio os includes do open.mp, + onde assinaturas longas em várias linhas são comuns — um + `ApplyActorAnimation`, com nove parâmetros em nove linhas, era invisível para + a engine +- **Comentário de documentação perdido quando `#pragma deprecated` ficava entre + ele e a declaração** — a varredura do doc caminha para cima e parava na + primeira linha que não fosse comentário, e a diretiva cortava o caminho. Passa + a pular a diretiva - **CodeQL: análise ausente em PRs de docs e dependências** — o repositório usava o *default setup* do CodeQL, que só analisa um pull request quando ele toca arquivos das linguagens configuradas. Um PR que mexia apenas em documentação ou diff --git a/CLAUDE.md b/CLAUDE.md index 9ab19ff..99b7d79 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ src/ types.rs ← ParsedFile, Symbol, SymbolKind, IncludeDirective, Param mod.rs ← re-exports públicos analyzer/ - codes.rs ← constantes PP0001–PP0013 + codes.rs ← constantes PP0001–PP0019 diagnostic.rs ← PawnDiagnostic, Severity, construtores includes.rs ← PP0001, PP0013 — resolve #include / #tryinclude semantic.rs ← PP0002, PP0003, PP0004 — erros estruturais diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c8890a3..9f86224 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,7 +41,7 @@ O binário de debug é detectado automaticamente pela extensão PawnPro se estiv ``` src/ parser/ ← lexer, parser de símbolos e tipos - analyzer/ ← diagnósticos PP0001–PP0017 + analyzer/ ← diagnósticos PP0001–PP0019 intellisense/ ← completions, hover, signature, codelens, references, semantic tokens, formatter workspace.rs ← orquestra análise de cada arquivo server.rs ← handlers LSP diff --git a/README.md b/README.md index 0cc369a..6fffafc 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ [![Clippy](https://img.shields.io/github/actions/workflow/status/NullSablex/PawnPro-Engine/ci.yml?style=flat-square&label=Clippy&logo=rust)](https://github.com/NullSablex/PawnPro-Engine/actions/workflows/ci.yml) [![Security Audit](https://img.shields.io/github/actions/workflow/status/NullSablex/PawnPro-Engine/ci.yml?style=flat-square&label=Security%20Audit&logo=rust)](https://github.com/NullSablex/PawnPro-Engine/actions/workflows/ci.yml) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/NullSablex/PawnPro-Engine/badge?style=flat-square)](https://scorecard.dev/viewer/?uri=github.com/NullSablex/PawnPro-Engine) + [![Stars](https://img.shields.io/github/stars/NullSablex/PawnPro-Engine?style=flat-square&logo=github&label=stars)](https://github.com/NullSablex/PawnPro-Engine/stargazers) [![License](https://img.shields.io/badge/licença-Source--Available-blue?style=flat-square)](LICENSE.md) ![Windows x64](https://img.shields.io/badge/Windows-x64-0078D4?style=flat-square&logo=windows11&logoColor=white) @@ -15,17 +16,19 @@ ![macOS x64 · arm64](https://img.shields.io/badge/macOS-x64%20·%20arm64-000000?style=flat-square&logo=apple&logoColor=white) -Motor IntelliSense para a linguagem **Pawn** — servidor LSP em Rust integrado à extensão [PawnPro](https://github.com/NullSablex/PawnPro) para Visual Studio Code. +Motor de análise para a linguagem **Pawn** (SA-MP / open.mp) — um servidor LSP escrito em Rust, que dá à extensão [PawnPro](https://github.com/NullSablex/PawnPro) o IntelliSense e os diagnósticos. ## O que é -`pawnpro-engine` é o núcleo de análise do PawnPro. Roda como processo separado e se comunica com o editor via **Language Server Protocol (LSP)** sobre stdin/stdout — o mesmo protocolo usado por `rust-analyzer` e `clangd`. +`pawnpro-engine` é o núcleo de análise do PawnPro. Roda como **processo separado** e conversa com o editor pelo **Language Server Protocol** sobre stdin/stdout — o mesmo protocolo do `rust-analyzer` e do `clangd`. -A extensão PawnPro inicia o motor automaticamente ao detectar o binário. Se o binário não estiver presente, a extensão recua para o modo TypeScript como fallback transparente. +Estar fora do processo do editor é o que permite analisar um gamemode inteiro, com todos os seus includes transitivos, sem travar a digitação. Não é realce por expressão regular: o código é tokenizado e analisado de verdade, e é daí que vêm a resolução de símbolos entre arquivos, a contagem de referências e os diagnósticos com posição exata. + +A extensão inicia o motor automaticamente quando encontra o binário; ele acompanha a instalação e não exige Rust nem nada instalado à parte. ## Capacidades -- **Diagnósticos** — 17 códigos `PP####` cobrindo erros de estrutura, código morto, símbolos não declarados, depreciação e indentação (ver [docs/diagnostics.md](docs/diagnostics.md)). +- **Diagnósticos** — 19 códigos `PP####` cobrindo erros de estrutura, código morto, símbolos não declarados, depreciação, indentação, nomenclatura e `#pragma` malformado; 13 deles com correção automática (ver [docs/diagnostics.md](docs/diagnostics.md)). - **Completions** — símbolos de todos os includes transitivos com snippets de parâmetros; itens depreciados marcados. - **Hover** — assinatura e comentário de documentação formatado (Javadoc `@param` e XMLdoc ``, o formato do `omp-stdlib`); em `#include` mostra o caminho resolvido. - **Signature Help** — parâmetro ativo destacado ao digitar `(` e `,`. @@ -33,6 +36,8 @@ A extensão PawnPro inicia o motor automaticamente ao detectar o binário. Se o - **References** — `textDocument/references` (Shift+F12). - **Semantic Tokens** — coloração semântica com suporte a chamadas multiline. - **Formatação** — documento inteiro e seleção de intervalo. +- **Assistente de nomenclatura** — convenções de caixa por categoria, com padrão próprio por expressão regular para o que os estilos prontos não descrevem. +- **Code actions** — correção automática para 13 dos diagnósticos. - **Invalidação granular** — ao salvar um include, o motor republica automaticamente os diagnósticos de todos os arquivos abertos que dependem dele, transitivamente. Para detalhes do protocolo e das opções de configuração, consulte [docs/lsp.md](docs/lsp.md). diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 433db1f..09d0c5f 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -24,6 +24,44 @@ O motor emite diagnósticos identificados por códigos `PP####`. | `PP0016` | Aviso¹ | Função sem keyword declarada mas nunca chamada | | `PP0017` | Aviso | Indentação inconsistente dentro de um bloco | | `PP0018` | Hint | Nome de identificador pobre (assistente de nomes; desligado por padrão) | +| `PP0019` | Aviso | `#pragma` desconhecido ou malformado | + +### PP0019 — `#pragma` desconhecido ou malformado + +O compilador rejeita uma diretiva que não conheça (erro 207), mas só na compilação. Este diagnóstico antecipa o erro e oferece a correção. + +Cobre dois casos: + +- **Nome não reconhecido** — comparado com a lista do compilador (`sc2.c`); quando há uma diretiva próxima, é sugerida: `#pragma deprected` → `#pragma deprecated`. +- **Mensagem de `deprecated` entre aspas** — a diretiva toma **o resto da linha como texto livre**, sem aspas: + + ```pawn + #pragma deprecated Use BanPlayerFor // correto + #pragma deprecated "Use BanPlayerFor" // as aspas entram na mensagem + ``` + + Aspas no meio do texto são literais legítimas e não são sinalizadas. + +Nos dois casos há *quick fix*. + +## Correções rápidas (quick fixes) + +Onde a correção é determinística, o diagnóstico vem com uma ação aplicável por clique (`Ctrl+.` / lâmpada): + +| Código | Ação | +|--------|------| +| `PP0002`, `PP0003` | Remover o corpo `{ … }` que o compilador não aceita em `native`/`forward` | +| `PP0004` | Adicionar corpo vazio, ou converter `public`/`stock` em `forward` | +| `PP0005`, `PP0006`, `PP0009`, `PP0016` | Remover a declaração não utilizada | +| `PP0010` | Trocar por um símbolo conhecido de nome parecido (erro de digitação) | +| `PP0011`, `PP0012` | Remover o `#define` / `#include` não utilizado | +| `PP0017` | Reindentar a linha, usando o estilo de formatação configurado | +| `PP0018` | Renomear para o estilo configurado | +| `PP0019` | Corrigir o nome da diretiva, ou remover as aspas da mensagem | + +Os demais não têm correção automática: `PP0001` e `PP0013` dependem de onde o arquivo está; `PP0007` e `PP0008` dependem do que usar no lugar (que só quem escreveu sabe); `PP0014` e `PP0015` são informativos — remover uma `native` ou um `forward` de uma include quebraria quem a consome. + +As remoções por "não utilizado" não são oferecidas em arquivos `.inc`, onde o símbolo costuma existir justamente para quem inclui o arquivo. A remoção de corpo ilegal (`PP0002`/`PP0003`), por ser erro de sintaxe, vale em qualquer arquivo. > ¹ Marcados com `DiagnosticTag::UNNECESSARY` — o editor exibe o símbolo desbotado além do sublinhado diagnóstico. diff --git a/docs/lsp.md b/docs/lsp.md index 1749509..e5736b4 100644 --- a/docs/lsp.md +++ b/docs/lsp.md @@ -65,7 +65,19 @@ O motor registra três caracteres de disparo: | `#` | Completions de diretivas (`#include`, `#define`, `#if`, `#ifdef`, etc.) com snippets | | `@` | Tags de documentação (`@param`, `@return`, `@remarks`) — apenas dentro de comentários | -Completions normais (sem trigger) listam símbolos de todos os includes transitivos com snippets de parâmetros. Itens marcados com `#pragma deprecated` aparecem com tag de depreciação. +Completions normais (sem trigger) listam símbolos de todos os includes transitivos com snippets de parâmetros. + +A lista é ordenada **pela proximidade do cursor**, não pelo alfabeto: + +1. parâmetros e variáveis locais da função sob o cursor; +2. símbolos declarados no próprio arquivo; +3. símbolos vindos dos includes; +4. palavras-chave da linguagem; +5. qualquer símbolo marcado com `#pragma deprecated` — aparece com a tag de depreciação e vai para o fim, seja qual for sua origem. + +Dentro de um grupo, a ordem é alfabética sem diferenciar maiúsculas. + +Um projeto com muitos includes chega a milhares de símbolos. A resposta é cortada em **1000 itens** e marcada como `isIncomplete`, o que faz o editor pedir a lista de novo conforme o prefixo cresce — o mecanismo do LSP para listas grandes. Como o corte vem depois da ordenação, o que se perde são os itens mais distantes do cursor. ## Comentários de documentação diff --git a/docs/naming-assistant.md b/docs/naming-assistant.md index a6be230..32d22cb 100644 --- a/docs/naming-assistant.md +++ b/docs/naming-assistant.md @@ -78,11 +78,14 @@ Em `.pawnpro/config.json`, seção `naming` (genérica, sem domínio): "naming": { "enabled": true, "style": { - "functions": "camelCase", // camelCase | snake_case | PascalCase | off - "globals": "camelCase", - "locals": "camelCase", - "constants": "UPPER_CASE", - "enums": "PascalCase" + // Lista por categoria: o nome passa se casar com QUALQUER item. + // Lista vazia desliga a checagem daquela categoria. + "functions": ["camelCase"], + "globals": ["camelCase", "/^g_[a-z][a-zA-Z0-9]*$/"], + "locals": ["camelCase"], + "constants": ["UPPER_CASE"], + "macros": ["UPPER_CASE"], + "parameters": ["camelCase"] }, "minLength": 2, "allowShortInLoops": ["i", "j", "k"], @@ -140,7 +143,22 @@ src/naming/ - Extração de locais: `src/naming/locals.rs` varre os tokens (o `StmtTree` não guarda o identificador do `VarDecl`). - **Estilo de caixa** (`src/naming/style.rs`): por categoria, em - `analysis.naming.style` (`functions`/`globals`/`locals`/`constants`/ - `parameters`), cada um `camelCase`/`snake_case`/`PascalCase`/`UPPER_CASE`/`Capitalized_Snake`/`off`. - Padrão `off` em todas — só checa o que o usuário pedir. Ordem das regras: - placeholder → comprimento → estilo (a mais específica vence). + `analysis.naming.style` (`functions`/`globals`/`locals`/`constants`/`macros`/ + `parameters`), cada uma uma **lista** de critérios — o nome passa se casar com + qualquer item; lista vazia desliga a checagem. Ordem das regras: placeholder → + comprimento → estilo (a mais específica vence). +- **`Rule`** é o critério aceito: `Builtin(Case)` para os cinco estilos + embutidos, ou `Custom { source, re }` para um padrão do usuário escrito entre + barras (`/^g_[a-z][a-zA-Z0-9]*$/`). É um tipo à parte de `Case` porque `Case` é + `Copy` e alimenta o `suggest`, que **gera** nomes — de um regex arbitrário não + se deriva um nome, então `Rule::builtin()` devolve `None` para eles e o quick + fix não oferece sugestão inválida. + - O padrão é compilado âncorado (`^(?:…)$`): descreve o nome inteiro, e o + agrupamento impede que uma alternância no topo ancore só os extremos. + - O `_` inicial **não** é removido antes da comparação, ao contrário dos + embutidos: quem escreve o padrão decide se o aceita. + - Um padrão inválido devolve `None` em `Rule::from_config` — a configuração é + do usuário e não pode derrubar a análise; a categoria só perde aquele + critério. + - `NameIssueKind::WrongStyle` carrega `Vec` (e não `&'static str`) + porque o rótulo de um regex só existe em runtime. diff --git a/src/analyzer/codes.rs b/src/analyzer/codes.rs index 423f7a6..1b7b2fa 100644 --- a/src/analyzer/codes.rs +++ b/src/analyzer/codes.rs @@ -16,3 +16,4 @@ pub const PP0015: &str = "PP0015"; // Forward declarado mas nunca chamado pub const PP0016: &str = "PP0016"; // Função plain (sem keyword) declarada mas nunca chamada pub const PP0017: &str = "PP0017"; // Indentação inconsistente dentro de um bloco pub const PP0018: &str = "PP0018"; // Nome de identificador pobre (assistente de nomes) +pub const PP0019: &str = "PP0019"; // #pragma desconhecido ou malformado diff --git a/src/analyzer/mod.rs b/src/analyzer/mod.rs index 0d20699..23dd766 100644 --- a/src/analyzer/mod.rs +++ b/src/analyzer/mod.rs @@ -5,6 +5,7 @@ pub mod hints; pub mod includes; pub mod indentation; pub mod naming; +pub mod pragmas; pub mod semantic; pub mod undefined; pub mod unused; diff --git a/src/analyzer/pragmas.rs b/src/analyzer/pragmas.rs new file mode 100644 index 0000000..2b2be0f --- /dev/null +++ b/src/analyzer/pragmas.rs @@ -0,0 +1,240 @@ +//! Validação das diretivas `#pragma`. +//! +//! O compilador rejeita um `#pragma` que não conheça (erro 207), mas só na +//! compilação; aqui o erro aparece enquanto se escreve. A lista abaixo espelha +//! `sc2.c` do compilador do open.mp. + +use crate::analyzer::{codes, diagnostic::PawnDiagnostic}; +use crate::messages::{Locale, MsgKey, msg}; +use crate::parser::lexer::strip_line_comments; +use crate::similar::closest; +use crate::util::to_u32; + +/// Diretivas aceitas pelo compilador (`sc2.c`). +const KNOWN: &[&str] = &[ + "align", + "amxlimit", + "amxram", + "codepage", + "compat", + "compress", + "ctrlchar", + "deprecated", + "disable", + "dynamic", + "enable", + "library", + "naked", + "nodestruct", + "option", + "pack", + "pop", + "push", + "rational", + "semicolon", + "tabsize", + "unread", + "unused", + "unwritten", + "warning", +]; + +/// O que fazer com um `#pragma` malformado, além de apontá-lo. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PragmaFix { + /// Trocar o nome da diretiva pelo desta sugestão. + Rename(String), + /// Remover as aspas em volta da mensagem de `deprecated`. + Unquote(String), +} + +/// Uma diretiva `#pragma` malformada, já com a correção sugerida. +pub struct PragmaIssue { + pub line: u32, + pub col: u32, + pub col_end: u32, + /// A diretiva como escrita, para compor a mensagem. + pub word: String, + pub kind: IssueKind, + pub fix: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum IssueKind { + Unknown, + UnknownWithSuggestion(&'static str), + DeprecatedQuoted, +} + +impl PragmaIssue { + /// Mensagem no idioma resolvido. + pub fn message(&self, locale: Locale) -> String { + match &self.kind { + IssueKind::Unknown => msg(locale, MsgKey::PragmaUnknown).replace("{}", &self.word), + IssueKind::UnknownWithSuggestion(s) => msg(locale, MsgKey::PragmaUnknownDidYouMean) + .replace("{}", &self.word) + .replace("{sug}", s), + IssueKind::DeprecatedQuoted => msg(locale, MsgKey::PragmaDeprecatedQuoted).to_string(), + } + } +} + +/// Verifica as diretivas `#pragma` do texto. +pub fn analyze_pragmas(text: &str, locale: Locale) -> Vec { + collect_issues(text) + .into_iter() + .map(|i| { + let message = i.message(locale); + PawnDiagnostic::warning(i.line, i.col, i.col_end, codes::PP0019, message) + }) + .collect() +} + +/// Separado de `analyze_pragmas` para que o quick fix reaproveite a análise. +pub fn collect_issues(text: &str) -> Vec { + let mut out = Vec::new(); + let mut in_block = false; + for (idx, raw) in text.lines().enumerate() { + let stripped = strip_line_comments(raw, in_block); + in_block = stripped.in_block; + let line = &stripped.text; + + let trimmed = line.trim_start(); + let Some(rest) = trimmed.strip_prefix('#') else { + continue; + }; + let rest = rest.trim_start(); + let Some(rest) = rest.strip_prefix("pragma") else { + continue; + }; + if !rest.is_empty() && !rest.starts_with(|c: char| c.is_whitespace()) { + continue; + } + let after = rest.trim_start(); + // Coluna do nome da diretiva, contada no texto original. + let name_col = line.len() - after.len(); + + let word: String = after + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect(); + if word.is_empty() { + continue; + } + + if !KNOWN.contains(&word.to_ascii_lowercase().as_str()) { + let suggestion = closest(&word, KNOWN.iter().copied()); + out.push(PragmaIssue { + line: to_u32(idx), + col: to_u32(name_col), + col_end: to_u32(name_col + word.len()), + kind: suggestion.map_or(IssueKind::Unknown, IssueKind::UnknownWithSuggestion), + word, + fix: suggestion.map(|s| PragmaFix::Rename(s.to_string())), + }); + continue; + } + + // `deprecated` toma o resto da linha como texto livre: aspas em volta + // entram na mensagem em vez de delimitá-la. + if word.eq_ignore_ascii_case("deprecated") { + let arg = after[word.len()..].trim(); + if let Some(inner) = unquoted(arg) { + let arg_col = line.len() - after.len() + (after.len() - after[word.len()..].len()); + let arg_start = arg_col + (after[word.len()..].len() - arg.len()); + out.push(PragmaIssue { + line: to_u32(idx), + col: to_u32(arg_start), + col_end: to_u32(arg_start + arg.len()), + word, + kind: IssueKind::DeprecatedQuoted, + fix: Some(PragmaFix::Unquote(inner.to_string())), + }); + } + } + } + out +} + +/// O conteúdo de `s` quando ele está inteiro entre aspas duplas. +fn unquoted(s: &str) -> Option<&str> { + let inner = s.strip_prefix('"')?.strip_suffix('"')?; + // Aspas no meio do texto são literais legítimas, não delimitadores. + (!inner.contains('"')).then_some(inner) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn issues(src: &str) -> Vec { + collect_issues(src) + } + + #[test] + fn known_pragmas_are_accepted() { + for src in [ + "#pragma deprecated Use OutraFuncao", + "#pragma tabsize 4", + "#pragma unused x", + "#pragma option -d3", + " # pragma semicolon 1", + ] { + assert!(issues(src).is_empty(), "{src}"); + } + } + + #[test] + fn typo_suggests_the_closest_directive() { + let i = issues("#pragma deprected Use Outra"); + assert_eq!(i.len(), 1); + assert_eq!(i[0].fix, Some(PragmaFix::Rename("deprecated".into()))); + assert!( + i[0].message(Locale::En).contains("deprecated"), + "{}", + i[0].message(Locale::En) + ); + } + + #[test] + fn unknown_without_a_close_match_has_no_fix() { + let i = issues("#pragma zzzzzzzz"); + assert_eq!(i.len(), 1); + assert_eq!(i[0].fix, None); + } + + #[test] + fn quoted_deprecated_message_is_flagged() { + let i = issues("#pragma deprecated \"Use BanPlayerFor\""); + assert_eq!(i.len(), 1); + assert_eq!( + i[0].fix, + Some(PragmaFix::Unquote("Use BanPlayerFor".into())) + ); + } + + #[test] + fn unquoted_message_is_fine_and_quotes_inside_are_kept() { + assert!(issues("#pragma deprecated Use BanPlayerFor").is_empty()); + // Aspas no meio são texto legítimo, não delimitadores. + assert!(issues(r#"#pragma deprecated diga "olá" a ela"#).is_empty()); + } + + #[test] + fn deprecated_without_message_is_fine() { + assert!(issues("#pragma deprecated").is_empty()); + } + + #[test] + fn directive_inside_a_comment_is_ignored() { + assert!(issues("// #pragma deprected x").is_empty()); + assert!(issues("/*\n#pragma deprected x\n*/").is_empty()); + } + + #[test] + fn column_points_at_the_directive_name() { + let i = issues(" #pragma deprected x"); + assert_eq!(i[0].col, 10); + assert_eq!(i[0].col_end, 19); + } +} diff --git a/src/intellisense/completion.rs b/src/intellisense/completion.rs index bd134f6..59c91bc 100644 --- a/src/intellisense/completion.rs +++ b/src/intellisense/completion.rs @@ -298,7 +298,7 @@ fn kw_to_item(kw: &KwSnippet, locale: Locale) -> CompletionItem { } else { InsertTextFormat::PLAIN_TEXT }), - sort_text: Some(format!("9_{}", kw.label)), + sort_text: Some(Rank::Keyword.sort_text(kw.label)), ..Default::default() } } @@ -480,11 +480,21 @@ pub fn get_completions( let mut items: Vec = Vec::new(); let mut seen: HashSet = HashSet::new(); + // Os símbolos do próprio arquivo vêm primeiro em `all_syms`; conhecê-los + // por nome permite ranquear o resto como vindo de include. + let own: HashSet<&str> = parsed.symbols.iter().map(|s| s.name.as_str()).collect(); for sym in &all_syms { if !seen.insert(sym.name.clone()) { continue; } - items.push(build_symbol_item(sym)); + let rank = if sym.deprecated { + Rank::Deprecated + } else if own.contains(sym.name.as_str()) { + Rank::File + } else { + Rank::Included + }; + items.push(build_symbol_item(sym, rank)); } if let Some(text) = state.get_text(uri) { @@ -503,7 +513,7 @@ pub fn get_completions( detail: Some(msg(locale, MsgKey::KwLocal).to_string()), insert_text: Some(name.clone()), insert_text_format: Some(InsertTextFormat::PLAIN_TEXT), - sort_text: Some(format!("1_{name}")), + sort_text: Some(Rank::Local.sort_text(name)), ..Default::default() }); } @@ -521,10 +531,59 @@ pub fn get_completions( } } + // Ordena aqui (e não só via `sort_text`) para que o corte abaixo mantenha os + // itens mais próximos do cursor, e não os primeiros a serem coletados. + items.sort_by(|a, b| a.sort_text.cmp(&b.sort_text)); items } -fn build_symbol_item(sym: &crate::parser::types::Symbol) -> CompletionItem { +/// Teto de itens devolvidos por chamada. +/// +/// Um projeto com muitos includes chega a milhares de símbolos, e mandá-los a +/// cada tecla trava a digitação. Com o corte, a resposta é marcada como +/// incompleta e o editor pede de novo conforme o prefixo cresce — que é o +/// mecanismo do LSP para listas grandes. +pub const MAX_COMPLETION_ITEMS: usize = 1000; + +/// Posição de um item na lista, do mais próximo do cursor ao mais distante. +/// +/// O `sort_text` que o editor usa para ordenar começa por este dígito, então a +/// ordem entre grupos é decidida aqui e não pelo alfabeto. Um símbolo +/// descontinuado cai para `Deprecated`, seja qual for sua origem: aparece na +/// lista (às vezes é mesmo o que se quer), mas nunca à frente de uma +/// alternativa viva. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum Rank { + /// Parâmetros e variáveis locais da função sob o cursor. + Local, + /// Símbolos declarados no próprio arquivo. + File, + /// Símbolos vindos dos includes. + Included, + /// Palavras-chave da linguagem. + Keyword, + /// Qualquer símbolo marcado com `#pragma deprecated`. + Deprecated, +} + +impl Rank { + fn prefix(self) -> char { + match self { + Rank::Local => '0', + Rank::File => '1', + Rank::Included => '2', + Rank::Keyword => '3', + Rank::Deprecated => '9', + } + } + + /// `sort_text` do item: o grupo decide primeiro, o nome desempata. + fn sort_text(self, label: &str) -> String { + format!("{}_{}", self.prefix(), label.to_ascii_lowercase()) + } +} + +fn build_symbol_item(sym: &crate::parser::types::Symbol, rank: Rank) -> CompletionItem { use crate::parser::types::SymbolKind::{ Const, Define, Enum, Forward, Native, Plain, Public, Static, StaticConst, Stock, Variable, }; @@ -580,7 +639,7 @@ fn build_symbol_item(sym: &crate::parser::types::Symbol) -> CompletionItem { }), insert_text, insert_text_format, - sort_text: Some(format!("0_{}", sym.name)), + sort_text: Some(rank.sort_text(&sym.name)), ..Default::default() }; @@ -590,3 +649,85 @@ fn build_symbol_item(sym: &crate::parser::types::Symbol) -> CompletionItem { item } + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser::types::{Symbol, SymbolKind}; + + fn sym(name: &str, deprecated: bool) -> Symbol { + Symbol { + name: name.to_string(), + kind: SymbolKind::Native, + signature: Some(format!("{name}()")), + params: vec![], + deprecated, + deprecated_message: None, + doc: None, + line: 0, + col: 0, + } + } + + #[test] + fn rank_orders_from_the_cursor_outwards() { + let mut ranks = [ + Rank::Deprecated, + Rank::Keyword, + Rank::Included, + Rank::File, + Rank::Local, + ]; + ranks.sort_unstable(); + assert_eq!( + ranks, + [ + Rank::Local, + Rank::File, + Rank::Included, + Rank::Keyword, + Rank::Deprecated + ] + ); + } + + #[test] + fn sort_text_puts_locals_before_includes() { + // O que importa é a ordem entre grupos, não o alfabeto: uma local + // chamada "zz" ainda vence uma nativa chamada "aa". + let local = Rank::Local.sort_text("zz"); + let included = Rank::Included.sort_text("aa"); + assert!(local < included, "{local} deveria vir antes de {included}"); + } + + #[test] + fn deprecated_sinks_below_everything_else() { + let dep = Rank::Deprecated.sort_text("aaa"); + for rank in [Rank::Local, Rank::File, Rank::Included, Rank::Keyword] { + let other = rank.sort_text("zzz"); + assert!(other < dep, "{other} deveria vir antes de {dep}"); + } + } + + #[test] + fn sort_text_is_case_insensitive_within_a_group() { + // Sem normalizar a caixa, "Zebra" viria antes de "alfa" (ASCII). + let upper = Rank::Included.sort_text("Zebra"); + let lower = Rank::Included.sort_text("alfa"); + assert!(lower < upper); + } + + #[test] + fn deprecated_symbol_is_ranked_and_tagged() { + let item = build_symbol_item(&sym("BanEx", true), Rank::Deprecated); + assert!(item.sort_text.unwrap().starts_with('9')); + assert_eq!(item.tags, Some(vec![CompletionItemTag::DEPRECATED])); + } + + #[test] + fn live_symbol_keeps_its_group_and_has_no_tag() { + let item = build_symbol_item(&sym("BanPlayerFor", false), Rank::File); + assert!(item.sort_text.unwrap().starts_with('1')); + assert_eq!(item.tags, None); + } +} diff --git a/src/intellisense/hover.rs b/src/intellisense/hover.rs index fbfd3f4..89ea5d8 100644 --- a/src/intellisense/hover.rs +++ b/src/intellisense/hover.rs @@ -118,7 +118,13 @@ fn format_symbol(sym: &Symbol, locale: crate::messages::Locale) -> Hover { }; if sym.deprecated { - let _ = write!(md, "\n\n> {}", msg(locale, MsgKey::HoverDeprecated)); + // Sem blockquote: o editor recua o bloco inteiro, e o `---` seguinte + // passa a ser lido como continuação dele. + let _ = write!(md, "\n\n{}", msg(locale, MsgKey::HoverDeprecated)); + // A mensagem da diretiva costuma dizer o que usar no lugar. + if let Some(m) = sym.deprecated_message.as_deref().filter(|m| !m.is_empty()) { + let _ = write!(md, " — {m}"); + } } if let Some(rendered) = sym @@ -138,3 +144,67 @@ fn format_symbol(sym: &Symbol, locale: crate::messages::Locale) -> Hover { range: None, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::messages::Locale; + use crate::parser::types::SymbolKind; + + fn sym(deprecated: bool, message: Option<&str>, doc: Option<&str>) -> Symbol { + Symbol { + name: "BanirComMotivo".into(), + kind: SymbolKind::Stock, + signature: Some("BanirComMotivo(playerid)".into()), + params: vec![], + deprecated, + deprecated_message: message.map(str::to_string), + doc: doc.map(str::to_string), + line: 0, + col: 0, + } + } + + fn markdown(h: &Hover) -> String { + match &h.contents { + HoverContents::Markup(m) => m.value.clone(), + _ => panic!("esperado markup"), + } + } + + #[test] + fn deprecation_is_not_a_blockquote() { + // `>` faz o editor recuar o bloco e engolir o `---` seguinte como + // continuação — foi o que deixava o hover torto. + let md = markdown(&format_symbol(&sym(true, None, None), Locale::PtBr)); + assert!(!md.contains('>'), "{md}"); + assert!(md.contains("Depreciado"), "{md}"); + } + + #[test] + fn deprecation_message_is_shown_in_the_hover() { + let md = markdown(&format_symbol( + &sym(true, Some("Use BanPlayerFor"), None), + Locale::PtBr, + )); + assert!(md.contains("Use BanPlayerFor"), "{md}"); + } + + #[test] + fn signature_comes_first_and_doc_after_the_rule() { + let md = markdown(&format_symbol( + &sym(false, None, Some("/**\n * Bane alguém.\n */")), + Locale::PtBr, + )); + assert!(md.starts_with("```pawn\nstock BanirComMotivo(playerid)\n```")); + assert!(md.contains("\n---\n"), "{md}"); + assert!(md.contains("Bane alguém."), "{md}"); + } + + #[test] + fn a_symbol_without_doc_still_shows_its_signature() { + let md = markdown(&format_symbol(&sym(false, None, None), Locale::PtBr)); + assert!(md.contains("BanirComMotivo(playerid)")); + assert!(!md.contains("---"), "sem doc não há regra: {md}"); + } +} diff --git a/src/intellisense/mod.rs b/src/intellisense/mod.rs index 15dea55..2dbedea 100644 --- a/src/intellisense/mod.rs +++ b/src/intellisense/mod.rs @@ -13,12 +13,12 @@ mod semantic_tokens; mod signature; pub use codelens::get_code_lens; -pub use completion::{get_at_completions, get_completions}; +pub use completion::{MAX_COMPLETION_ITEMS, get_at_completions, get_completions}; pub use docs::{DocLabels, parse_doc}; pub use format_style::{BracePlacement, FormatStyle, Preset}; pub use formatter::{format_document, format_range}; pub use hover::get_hover; -pub use quickfix::{removal_kind, removal_range}; +pub use quickfix::{RemovalKind, removal_kind, removal_range}; pub use references::get_references; pub use rename::{get_rename, prepare_rename}; pub use semantic_tokens::{get_semantic_tokens, semantic_tokens_legend}; @@ -58,6 +58,23 @@ pub(crate) fn extract_word(line: &str, col: usize) -> Option { Some(chars[start..end].iter().collect()) } +/// O símbolo conhecido mais parecido com `name`, para sugerir em PP0010. +/// +/// Considera os símbolos do arquivo e de todos os includes transitivos — é o +/// mesmo universo que o autocomplete oferece, então a sugestão nunca aponta +/// para algo que o arquivo não enxerga. +pub fn suggest_symbol( + state: &WorkspaceState, + file_path: &Path, + inc_paths: &[PathBuf], + parsed: &ParsedFile, + name: &str, +) -> Option { + let all = collect_all_symbols(state, file_path, inc_paths, parsed); + let names: Vec<&str> = all.iter().map(|s| s.name.as_str()).collect(); + crate::similar::closest(name, names).map(str::to_string) +} + pub(crate) fn collect_all_symbols( state: &WorkspaceState, file_path: &Path, diff --git a/src/intellisense/quickfix.rs b/src/intellisense/quickfix.rs index 7198481..2b07a4c 100644 --- a/src/intellisense/quickfix.rs +++ b/src/intellisense/quickfix.rs @@ -18,6 +18,12 @@ pub enum RemovalKind { Function, /// Parâmetro: apenas o identificador dentro da lista (sem mexer em chamadas). Parameter, + /// Diretiva de uma linha (`#define`, `#include`): a linha inteira. Uma + /// continuação com `\` no fim arrasta as linhas seguintes. + Directive, + /// Corpo `{ … }` de um `native`/`forward`, que o compilador não aceita: + /// remove do `{` ao `}`, deixando a declaração terminada em `;`. + IllegalBody, } /// Mapeia um código de diagnóstico para o tipo de remoção, ou `None` se o código @@ -28,6 +34,8 @@ pub fn removal_kind(code: &str) -> Option { "PP0005" => Some(RemovalKind::Variable), "PP0006" | "PP0016" => Some(RemovalKind::Function), "PP0009" => Some(RemovalKind::Parameter), + "PP0011" | "PP0012" => Some(RemovalKind::Directive), + "PP0002" | "PP0003" => Some(RemovalKind::IllegalBody), _ => None, } } @@ -43,9 +51,77 @@ pub fn removal_range(text: &str, line: u32, col: u32, kind: RemovalKind) -> Opti RemovalKind::Variable => variable_range(cur, line), RemovalKind::Function => function_range(&lines, line), RemovalKind::Parameter => parameter_range(cur, line, col), + RemovalKind::Directive => directive_range(&lines, line), + RemovalKind::IllegalBody => illegal_body_range(&lines, line), } } +/// Corpo ilegal de `native`/`forward`: do `{` até o `}` que o fecha. O `{` pode +/// estar na própria linha da declaração ou na seguinte. +/// +/// Devolve `None` se as chaves não fecharem no arquivo — melhor não oferecer o +/// fix do que apagar até o fim. +fn illegal_body_range(lines: &[&str], line: u32) -> Option { + let decl = line as usize; + // Acha a linha e a coluna do `{` de abertura (declaração ou a seguinte). + let (open_line, open_col) = + (decl..=decl + 1).find_map(|i| lines.get(i).and_then(|l| l.find('{').map(|c| (i, c))))?; + + let mut depth = 0i32; + for (i, l) in lines.iter().enumerate().skip(open_line) { + let from = if i == open_line { open_col } else { 0 }; + for (c, ch) in l.char_indices().skip_while(|(c, _)| *c < from) { + match ch { + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + return Some(Range { + start: Position { + line: to_u32(open_line), + character: to_u32(open_col), + }, + end: Position { + line: to_u32(i), + character: to_u32(c + 1), + }, + }); + } + } + _ => {} + } + } + } + None +} + +/// Diretiva: a linha inteira, mais as seguintes enquanto a anterior terminar em +/// `\` (continuação de macro). Só age quando a linha começa mesmo com `#`. +fn directive_range(lines: &[&str], line: u32) -> Option { + let start = line as usize; + if !lines.get(start)?.trim_start().starts_with('#') { + return None; + } + let mut end = start; + while lines.get(end).is_some_and(|l| l.trim_end().ends_with('\\')) { + // Uma continuação sem linha seguinte não existe; parar evita estourar. + if end + 1 >= lines.len() { + break; + } + end += 1; + } + Some(Range { + start: Position { + line: to_u32(start), + character: 0, + }, + end: Position { + line: to_u32(end + 1), + character: 0, + }, + }) +} + /// Variável: remove a linha inteira da declaração se ela contém só essa /// declaração (`new …;`). Conservador — só age quando a linha termina em `;` e /// começa com um qualificador de declaração. @@ -186,6 +262,57 @@ fn strip_for_braces(line: &str) -> String { #[cfg(test)] mod tests { + + #[test] + fn directive_removes_the_whole_line() { + let src = "#define MAX 10\nmain() {}\n"; + let r = removal_range(src, 0, 8, RemovalKind::Directive).unwrap(); + assert_eq!(r.start.line, 0); + assert_eq!(r.end.line, 1); + assert_eq!(r.end.character, 0); + } + + #[test] + fn directive_follows_backslash_continuations() { + let src = "#define LONGA(%0) \\\n foo(%0) \\\n bar(%0)\nmain() {}\n"; + let r = removal_range(src, 0, 8, RemovalKind::Directive).unwrap(); + assert_eq!(r.end.line, 3, "deve arrastar as continuações"); + } + + #[test] + fn directive_ignores_a_line_that_is_not_a_directive() { + assert!(removal_range("new x;\n", 0, 4, RemovalKind::Directive).is_none()); + } + + #[test] + fn illegal_body_on_the_same_line() { + let src = "native Foo() { return 1; }\n"; + let r = removal_range(src, 0, 7, RemovalKind::IllegalBody).unwrap(); + assert_eq!((r.start.line, r.start.character), (0, 13)); + assert_eq!((r.end.line, r.end.character), (0, 26)); + } + + #[test] + fn illegal_body_with_the_brace_on_the_next_line() { + let src = "forward Foo()\n{\n return 1;\n}\n"; + let r = removal_range(src, 0, 8, RemovalKind::IllegalBody).unwrap(); + assert_eq!(r.start.line, 1); + assert_eq!((r.end.line, r.end.character), (3, 1)); + } + + #[test] + fn illegal_body_handles_nested_braces() { + let src = "native Foo() { if (a) { b(); } }\n"; + let r = removal_range(src, 0, 7, RemovalKind::IllegalBody).unwrap(); + assert_eq!((r.end.line, r.end.character), (0, 32)); + } + + #[test] + fn illegal_body_unbalanced_gives_no_fix() { + // Sem `}` que feche: melhor não oferecer o fix do que apagar até o fim. + let src = "native Foo() {\n return 1;\n"; + assert!(removal_range(src, 0, 7, RemovalKind::IllegalBody).is_none()); + } use super::*; #[test] diff --git a/src/main.rs b/src/main.rs index 4c048db..b128ffb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod messages; mod naming; mod parser; mod server; +mod similar; mod text; mod util; mod workspace; diff --git a/src/messages/langs/en.rs b/src/messages/langs/en.rs index e64a345..a35b6b1 100644 --- a/src/messages/langs/en.rs +++ b/src/messages/langs/en.rs @@ -15,8 +15,13 @@ pub fn get(key: MsgKey) -> &'static str { MsgKey::DeclNoBody => "{} declaration \"{}\" has no body. Use \"forward\" for prototypes.", MsgKey::VarUnused => "\"{}\" variable declared but never used", MsgKey::StockUnused => "\"{}\" stock function declared but never used", - MsgKey::SymDeprecated => "\"{}\" is marked as deprecated", + MsgKey::SymDeprecated => "\"{}\" is deprecated", MsgKey::SymDeprecatedUsage => "\"{}\" is deprecated", + MsgKey::PragmaUnknown => "`#pragma {}` does not exist", + MsgKey::PragmaUnknownDidYouMean => "`#pragma {}` does not exist — use `{sug}`", + MsgKey::PragmaDeprecatedQuoted => { + "The message takes no quotes — they would end up in the text" + } MsgKey::SymFromDeprecatedFile => "\"{}\" belongs to a deprecated include", MsgKey::IncludeDeprecated => "\"{}\" is deprecated", MsgKey::ParamUnused => "Parameter \"{}\" declared but never used", diff --git a/src/messages/langs/es.rs b/src/messages/langs/es.rs index f965a56..2ead011 100644 --- a/src/messages/langs/es.rs +++ b/src/messages/langs/es.rs @@ -20,8 +20,11 @@ pub fn get(key: MsgKey) -> &'static str { } MsgKey::VarUnused => "variable \"{}\" declarada pero nunca usada", MsgKey::StockUnused => "función stock \"{}\" declarada pero nunca usada", - MsgKey::SymDeprecated => "\"{}\" está marcado como obsoleto", + MsgKey::SymDeprecated => "\"{}\" está obsoleto", MsgKey::SymDeprecatedUsage => "\"{}\" está obsoleto", + MsgKey::PragmaUnknown => "`#pragma {}` no existe", + MsgKey::PragmaUnknownDidYouMean => "`#pragma {}` no existe — usa `{sug}`", + MsgKey::PragmaDeprecatedQuoted => "El mensaje no lleva comillas — quedarían en el texto", MsgKey::SymFromDeprecatedFile => "\"{}\" pertenece a un include obsoleto", MsgKey::IncludeDeprecated => "\"{}\" está obsoleto", MsgKey::ParamUnused => "Parámetro \"{}\" declarado pero nunca usado", diff --git a/src/messages/langs/pt_br.rs b/src/messages/langs/pt_br.rs index 73e3f1c..da5f4e3 100644 --- a/src/messages/langs/pt_br.rs +++ b/src/messages/langs/pt_br.rs @@ -15,8 +15,11 @@ pub fn get(key: MsgKey) -> &'static str { MsgKey::DeclNoBody => "Declaração {} \"{}\" sem corpo. Use \"forward\" para protótipos.", MsgKey::VarUnused => "\"{}\" variável declarada mas não utilizada", MsgKey::StockUnused => "\"{}\" função stock declarada mas não utilizada", - MsgKey::SymDeprecated => "\"{}\" está marcado como depreciado", + MsgKey::SymDeprecated => "\"{}\" está depreciado", MsgKey::SymDeprecatedUsage => "\"{}\" está depreciado", + MsgKey::PragmaUnknown => "`#pragma {}` não existe", + MsgKey::PragmaUnknownDidYouMean => "`#pragma {}` não existe — use `{sug}`", + MsgKey::PragmaDeprecatedQuoted => "A mensagem não leva aspas — elas entrariam no texto", MsgKey::SymFromDeprecatedFile => "\"{}\" pertence a um include depreciado", MsgKey::IncludeDeprecated => "\"{}\" está depreciado", MsgKey::ParamUnused => "Parâmetro \"{}\" declarado mas não utilizado", diff --git a/src/messages/langs/ro.rs b/src/messages/langs/ro.rs index 2368e49..f29cce7 100644 --- a/src/messages/langs/ro.rs +++ b/src/messages/langs/ro.rs @@ -20,8 +20,11 @@ pub fn get(key: MsgKey) -> &'static str { } MsgKey::VarUnused => "variabila \"{}\" declarată dar niciodată folosită", MsgKey::StockUnused => "funcția stock \"{}\" declarată dar niciodată folosită", - MsgKey::SymDeprecated => "\"{}\" este marcat ca învechit", + MsgKey::SymDeprecated => "\"{}\" este învechit", MsgKey::SymDeprecatedUsage => "\"{}\" este învechit", + MsgKey::PragmaUnknown => "`#pragma {}` nu există", + MsgKey::PragmaUnknownDidYouMean => "`#pragma {}` nu există — folosește `{sug}`", + MsgKey::PragmaDeprecatedQuoted => "Mesajul nu ia ghilimele — ar ajunge în text", MsgKey::SymFromDeprecatedFile => "\"{}\" aparține unui include învechit", MsgKey::IncludeDeprecated => "\"{}\" este învechit", MsgKey::ParamUnused => "Parametrul \"{}\" declarat dar niciodată folosit", diff --git a/src/messages/langs/ru.rs b/src/messages/langs/ru.rs index f09e242..ac73f32 100644 --- a/src/messages/langs/ru.rs +++ b/src/messages/langs/ru.rs @@ -20,8 +20,11 @@ pub fn get(key: MsgKey) -> &'static str { } MsgKey::VarUnused => "переменная \"{}\" объявлена, но не используется", MsgKey::StockUnused => "stock-функция \"{}\" объявлена, но не используется", - MsgKey::SymDeprecated => "\"{}\" помечен как устаревший", + MsgKey::SymDeprecated => "\"{}\" устарел", MsgKey::SymDeprecatedUsage => "\"{}\" устарел", + MsgKey::PragmaUnknown => "`#pragma {}` не существует", + MsgKey::PragmaUnknownDidYouMean => "`#pragma {}` не существует — используйте `{sug}`", + MsgKey::PragmaDeprecatedQuoted => "Сообщение пишется без кавычек — они попадут в текст", MsgKey::SymFromDeprecatedFile => "\"{}\" принадлежит устаревшему include", MsgKey::IncludeDeprecated => "\"{}\" устарел", MsgKey::ParamUnused => "Параметр \"{}\" объявлен, но не используется", diff --git a/src/messages/mod.rs b/src/messages/mod.rs index 5fe52d3..1e808b1 100644 --- a/src/messages/mod.rs +++ b/src/messages/mod.rs @@ -43,6 +43,9 @@ pub enum MsgKey { StockUnused, SymDeprecated, SymDeprecatedUsage, + PragmaUnknown, + PragmaUnknownDidYouMean, + PragmaDeprecatedQuoted, SymFromDeprecatedFile, IncludeDeprecated, ParamUnused, @@ -143,6 +146,9 @@ mod tests { for loc in [Locale::PtBr, Locale::Es, Locale::Ru, Locale::Ro, Locale::En] { assert!(!msg(loc, MsgKey::HoverDeprecated).is_empty()); assert!(!msg(loc, MsgKey::HoverParams).is_empty()); + assert!(!msg(loc, MsgKey::PragmaUnknown).is_empty()); + assert!(!msg(loc, MsgKey::PragmaUnknownDidYouMean).is_empty()); + assert!(!msg(loc, MsgKey::PragmaDeprecatedQuoted).is_empty()); assert!(!msg(loc, MsgKey::HoverReturns).is_empty()); assert!(!msg(loc, MsgKey::HoverRemarks).is_empty()); } diff --git a/src/naming/mod.rs b/src/naming/mod.rs index 5cf12f3..5c6ed13 100644 --- a/src/naming/mod.rs +++ b/src/naming/mod.rs @@ -11,7 +11,6 @@ mod style; mod suggest; pub use locals::collect_local_decls; -pub use style::Case; use crate::config::NamingConfig; @@ -46,8 +45,10 @@ pub enum NameIssueKind { /// Identificador genérico da blocklist (`tmp`, `foo`, …). Placeholder, /// Caixa fora dos estilos aceitos para a categoria. Carrega os rótulos dos - /// estilos aceitos (ex.: `camelCase`, `snake_case`) para a mensagem. - WrongStyle(Vec<&'static str>), + /// estilos aceitos (ex.: `camelCase`, `snake_case`) para a mensagem. É + /// `String` porque um critério pode ser um regex do usuário, cujo rótulo só + /// existe em runtime. + WrongStyle(Vec), } /// Resultado da análise de um identificador. @@ -96,7 +97,11 @@ pub fn suggestions_for(name: &str, cfg: &NamingConfig) -> Vec { &s.parameters, ]; for raw in all.into_iter().flatten() { - if let Some(case) = Case::from_config(raw) + // Só os estilos embutidos geram sugestão: de um regex do usuário dá + // para saber se o nome passa, não como reescrevê-lo. + if let Some(case) = style::Rule::from_config(raw) + .as_ref() + .and_then(style::Rule::builtin) && let Some(suggestion) = suggest::to_style(name, case) && !out.contains(&suggestion) { diff --git a/src/naming/rules.rs b/src/naming/rules.rs index 2a45e3c..d529fc4 100644 --- a/src/naming/rules.rs +++ b/src/naming/rules.rs @@ -41,8 +41,8 @@ pub fn evaluate(site: &NameSite, cfg: &NamingConfig, lists: &ResolvedLists) -> O // nome não casar com NENHUM deles. Índices de loop curtos ficam isentos. if !tolerated_short { let accepted = accepted_styles(&cfg.style, site.category); - if !accepted.is_empty() && !accepted.iter().any(|c| style::matches(&site.name, *c)) { - let labels = accepted.iter().map(|c| style::label(*c)).collect(); + if !accepted.is_empty() && !accepted.iter().any(|r| r.matches(&site.name)) { + let labels = accepted.iter().map(|r| r.label().to_string()).collect(); return Some(issue(site, NameIssueKind::WrongStyle(labels))); } } @@ -52,7 +52,7 @@ pub fn evaluate(site: &NameSite, cfg: &NamingConfig, lists: &ResolvedLists) -> O /// Estilos aceitos para a categoria (vazio = sem checagem). Valores inválidos /// na configuração são ignorados. -fn accepted_styles(cfg: &StyleConfig, category: NameCategory) -> Vec { +fn accepted_styles(cfg: &StyleConfig, category: NameCategory) -> Vec { let raw = match category { NameCategory::Function => &cfg.functions, NameCategory::Global => &cfg.globals, @@ -62,7 +62,7 @@ fn accepted_styles(cfg: &StyleConfig, category: NameCategory) -> Vec &cfg.parameters, }; raw.iter() - .filter_map(|s| style::Case::from_config(s)) + .filter_map(|s| style::Rule::from_config(s)) .collect() } @@ -163,6 +163,38 @@ mod tests { assert_eq!(r.kind, NameIssueKind::Placeholder); } + #[test] + fn user_regex_accepts_its_own_convention() { + let c = cfg_style(&["/^g_[a-z][a-zA-Z0-9]*$/"]); + assert!(ev(&sited("g_playerHealth", false, NameCategory::Function), &c).is_none()); + } + + #[test] + fn user_regex_flags_what_it_does_not_match() { + let c = cfg_style(&["/^g_[a-z][a-zA-Z0-9]*$/"]); + let r = ev(&sited("playerHealth", false, NameCategory::Function), &c).unwrap(); + assert_eq!( + r.kind, + NameIssueKind::WrongStyle(vec!["/^g_[a-z][a-zA-Z0-9]*$/".to_string()]) + ); + } + + #[test] + fn builtin_and_user_regex_coexist() { + // A categoria aceita o nome que casar com QUALQUER critério. + let c = cfg_style(&["camelCase", "/^g_.+$/"]); + assert!(ev(&sited("playerHealth", false, NameCategory::Function), &c).is_none()); + assert!(ev(&sited("g_ANYTHING", false, NameCategory::Function), &c).is_none()); + assert!(ev(&sited("player_health", false, NameCategory::Function), &c).is_some()); + } + + #[test] + fn invalid_user_regex_is_ignored_without_breaking_the_rest() { + let c = cfg_style(&["/[unclosed/", "camelCase"]); + assert!(ev(&sited("playerHealth", false, NameCategory::Function), &c).is_none()); + assert!(ev(&sited("DoThing", false, NameCategory::Function), &c).is_some()); + } + fn cfg_style(functions: &[&str]) -> NamingConfig { let mut c = cfg(); c.style.functions = functions.iter().map(|s| (*s).to_string()).collect(); @@ -184,7 +216,10 @@ mod tests { &cfg_style(&["camelCase"]), ) .unwrap(); - assert_eq!(r.kind, NameIssueKind::WrongStyle(vec!["camelCase"])); + assert_eq!( + r.kind, + NameIssueKind::WrongStyle(vec!["camelCase".to_string()]) + ); } #[test] @@ -205,7 +240,7 @@ mod tests { let r = ev(&sited("do_thing", false, NameCategory::Function), &cfg).unwrap(); assert_eq!( r.kind, - NameIssueKind::WrongStyle(vec!["camelCase", "PascalCase"]) + NameIssueKind::WrongStyle(vec!["camelCase".to_string(), "PascalCase".to_string()]) ); } diff --git a/src/naming/style.rs b/src/naming/style.rs index 01145e2..d62e7fe 100644 --- a/src/naming/style.rs +++ b/src/naming/style.rs @@ -3,6 +3,8 @@ //! Determinístico e tolerante: um `_` inicial e dígitos não desqualificam um //! estilo. Na dúvida, não acusa. +use regex::Regex; + /// Estilo de caixa esperado para uma categoria de identificador. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Case { @@ -59,10 +61,74 @@ pub fn matches(name: &str, expected: Case) -> bool { } } -/// Rótulo legível do estilo, para a mensagem ao usuário. -#[must_use] -pub fn label(expected: Case) -> &'static str { - expected.label() +/// Um critério aceito por uma categoria: um dos estilos embutidos ou um regex +/// do usuário. +/// +/// Existe para o regex não precisar entrar em [`Case`], que é `Copy` e usado +/// por [`suggest`](super::suggest) para *gerar* nomes — coisa que um padrão +/// arbitrário não permite fazer. +#[derive(Debug, Clone)] +pub enum Rule { + Builtin(Case), + /// Regex âncorado, tal como escrito pelo usuário (para a mensagem). + Custom { + source: String, + re: Regex, + }, +} + +impl Rule { + /// Interpreta um item da lista de estilos. `/.../` (com barras) é regex; + /// qualquer outra coisa cai nos estilos embutidos. + /// + /// Um regex inválido devolve `None` — a configuração é do usuário e não + /// pode derrubar a análise; a categoria só perde aquele critério. + #[must_use] + pub fn from_config(s: &str) -> Option { + if let Some(body) = s.strip_prefix('/').and_then(|r| r.strip_suffix('/')) { + if body.is_empty() { + return None; + } + // Âncoras implícitas: o usuário descreve o nome inteiro, não um + // trecho dele — `[a-z]+` não deve aceitar `g_FOO`. + let anchored = format!("^(?:{body})$"); + return Regex::new(&anchored).ok().map(|re| Self::Custom { + source: s.to_string(), + re, + }); + } + Case::from_config(s).map(Self::Builtin) + } + + /// `true` se `name` satisfaz este critério. + #[must_use] + pub fn matches(&self, name: &str) -> bool { + match self { + Self::Builtin(c) => matches(name, *c), + // Sem remover `_` inicial: o padrão é do usuário e ele decide se o + // aceita. Tirar caracteres por conta própria contrariaria o regex. + Self::Custom { re, .. } => re.is_match(name), + } + } + + /// Rótulo para a mensagem de diagnóstico. + #[must_use] + pub fn label(&self) -> &str { + match self { + Self::Builtin(c) => c.label(), + Self::Custom { source, .. } => source, + } + } + + /// O estilo embutido, quando houver — usado para sugerir a renomeação. + /// `None` para regex: dá para validar um padrão, não para gerar um nome. + #[must_use] + pub fn builtin(&self) -> Option { + match self { + Self::Builtin(c) => Some(*c), + Self::Custom { .. } => None, + } + } } fn is_camel(s: &str) -> bool { @@ -114,6 +180,96 @@ fn is_cap_snake(s: &str) -> bool { mod tests { use super::*; + #[test] + fn catastrophic_pattern_is_linear_in_rust() { + // O mesmo padrão que trava um motor com backtracking. A crate `regex` + // garante tempo linear, então isto termina imediatamente. + let r = Rule::from_config("/^(a+)+$/").unwrap(); + let alvo = "a".repeat(60) + "X"; + let t = std::time::Instant::now(); + assert!(!r.matches(&alvo)); + assert!(t.elapsed().as_millis() < 500, "levou {:?}", t.elapsed()); + } + + #[test] + fn custom_rule_is_recognized_by_slashes() { + assert!(matches!( + Rule::from_config("/^g_[a-z]+$/"), + Some(Rule::Custom { .. }) + )); + assert!(matches!( + Rule::from_config("camelCase"), + Some(Rule::Builtin(Case::Camel)) + )); + // Sem barras não é regex: continua caindo nos embutidos (e falhando). + assert!(Rule::from_config("^g_[a-z]+$").is_none()); + } + + #[test] + fn custom_rule_matches_user_pattern() { + let r = Rule::from_config("/g_[a-zA-Z]+/").unwrap(); + assert!(r.matches("g_playerHealth")); + assert!(!r.matches("playerHealth")); + } + + #[test] + fn custom_rule_is_anchored() { + // Sem âncora implícita `[a-z]+` aceitaria qualquer nome que contivesse + // minúsculas — o usuário descreve o nome inteiro. + let r = Rule::from_config("/[a-z]+/").unwrap(); + assert!(r.matches("health")); + assert!(!r.matches("g_HEALTH")); + assert!(!r.matches("playerHealth")); + } + + #[test] + fn user_written_anchors_still_work() { + // Quem já escreve `^...$` não é penalizado pelo agrupamento interno. + let r = Rule::from_config("/^g_[a-z]+$/").unwrap(); + assert!(r.matches("g_health")); + assert!(!r.matches("g_Health")); + } + + #[test] + fn alternation_is_not_broken_by_anchoring() { + // `^(?:a|b)$` e não `^a|b$`, que ancoraria só os extremos. + let r = Rule::from_config("/foo|bar/").unwrap(); + assert!(r.matches("foo")); + assert!(r.matches("bar")); + assert!(!r.matches("xfooy")); + assert!(!r.matches("foobar")); + } + + #[test] + fn invalid_regex_is_ignored_not_fatal() { + assert!(Rule::from_config("/[unclosed/").is_none()); + assert!(Rule::from_config("//").is_none()); + } + + #[test] + fn custom_rule_keeps_leading_underscore() { + // Os embutidos toleram `_` inicial; o regex do usuário manda. + let r = Rule::from_config("/^[a-z]+$/").unwrap(); + assert!(!r.matches("_health")); + assert!(matches("_health", Case::Snake)); + } + + #[test] + fn custom_rule_label_is_the_source() { + let r = Rule::from_config("/^g_.+$/").unwrap(); + assert_eq!(r.label(), "/^g_.+$/"); + assert_eq!(Rule::from_config("camelCase").unwrap().label(), "camelCase"); + } + + #[test] + fn only_builtin_rules_suggest_renames() { + assert!(Rule::from_config("/^g_.+$/").unwrap().builtin().is_none()); + assert_eq!( + Rule::from_config("snake_case").unwrap().builtin(), + Some(Case::Snake) + ); + } + #[test] fn parses_config_values() { assert_eq!(Case::from_config("camelCase"), Some(Case::Camel)); @@ -171,7 +327,10 @@ mod tests { #[test] fn cap_snake_config_and_suggestion() { assert_eq!(Case::from_config("Capitalized_Snake"), Some(Case::CapSnake)); - assert_eq!(label(Case::CapSnake), "Capitalized_Snake"); + assert_eq!( + Rule::from_config("Capitalized_Snake").unwrap().label(), + "Capitalized_Snake" + ); } #[test] diff --git a/src/parser/lexer.rs b/src/parser/lexer.rs index b0995e2..0220c53 100644 --- a/src/parser/lexer.rs +++ b/src/parser/lexer.rs @@ -35,7 +35,23 @@ pub fn decode_bytes(bytes: &[u8]) -> String { /// /// Devolve a mensagem (vazia quando a diretiva não traz uma). pub fn pragma_deprecated_message(raw_line: &str) -> Option { - let t = raw_line.trim(); + let rest = pragma_deprecated_rest(raw_line)?; + // Um comentário na mesma linha não faz parte da mensagem. + let msg = strip_line_comments(rest, false).text; + Some(msg.trim().to_string()) +} + +/// `true` se a linha é a diretiva, sem montar a mensagem. +/// +/// `extract_doc` chama isto por linha ao caminhar para cima; extrair a mensagem +/// ali alocaria uma `String` por linha inspecionada, só para descartá-la. +pub fn is_pragma_deprecated(raw_line: &str) -> bool { + pragma_deprecated_rest(raw_line).is_some() +} + +/// Reconhece a diretiva e devolve o trecho após `deprecated`, ainda cru. +fn pragma_deprecated_rest(raw_line: &str) -> Option<&str> { + let t = raw_line.trim_start(); let rest = t.strip_prefix('#')?.trim_start(); let rest = rest.strip_prefix("pragma")?; // Exige separador: `#pragmadeprecated` não é a diretiva. @@ -46,9 +62,7 @@ pub fn pragma_deprecated_message(raw_line: &str) -> Option { if !rest.is_empty() && !rest.starts_with(|c: char| c.is_whitespace()) { return None; } - // Um comentário na mesma linha não faz parte da mensagem. - let msg = strip_line_comments(rest, false).text; - Some(msg.trim().to_string()) + Some(rest) } #[derive(Debug)] diff --git a/src/parser/symbols.rs b/src/parser/symbols.rs index 896e640..4cc92f7 100644 --- a/src/parser/symbols.rs +++ b/src/parser/symbols.rs @@ -1,7 +1,9 @@ use regex::Regex; use super::{ - lexer::{pragma_deprecated_message, strip_line_comments, update_brace_depth}, + lexer::{ + is_pragma_deprecated, pragma_deprecated_message, strip_line_comments, update_brace_depth, + }, types::{Deprecation, IncludeDirective, Param, ParsedFile, Symbol, SymbolKind}, }; use crate::util::to_u32; @@ -244,49 +246,75 @@ fn parse_params(raw: &str) -> Vec { params } +/// Extrai o comentário de documentação de um símbolo declarado em `line_idx`. +/// +/// A regra é a mesma do Javadoc/PHPDoc: vale **um bloco só, o imediatamente +/// acima da declaração**. Isso evita o modo de falha em que a varredura sobe o +/// arquivo acumulando comentários de outras seções — e dispensa tratar régua, +/// cabeçalho e ornamento como casos especiais, porque nada além do bloco +/// vizinho é considerado. +/// +/// São aceitos: +/// +/// - o bloco `/* … */` (em uma ou várias linhas) colado na declaração; +/// - uma sequência contígua de linhas `//`, colada na declaração — convenção +/// comum em Pawn. +/// +/// Entre o comentário e a declaração pode haver `#pragma deprecated`, que é a +/// diretiva que marca o símbolo. Uma linha em branco separa: o que estiver +/// acima dela pertence a outra coisa. fn extract_doc(lines: &[&str], line_idx: usize) -> Option { - let mut doc_lines = Vec::new(); - let mut found = false; - // Caminha para cima a partir da linha anterior. Índices em `usize` com - // decremento via `checked_sub` evitam o uso de `isize` (e os casts que ele - // exigiria); ao chegar em 0 o loop termina. - let mut i = line_idx.checked_sub(1); - while let Some(idx) = i { - let l = lines[idx].trim(); - if l.is_empty() { - if found { - break; - } - i = idx.checked_sub(1); - continue; + // Pula o `#pragma deprecated` entre o comentário e a declaração. + let mut idx = line_idx.checked_sub(1)?; + while is_pragma_deprecated(lines[idx].trim()) { + idx = idx.checked_sub(1)?; + } + + let last = lines[idx].trim(); + if last.ends_with("*/") { + block_doc(lines, idx) + } else if last.starts_with("//") { + line_doc(lines, idx) + } else { + // Qualquer outra coisa (código, linha em branco) rompe a adjacência. + None + } +} + +/// Bloco `/* … */` que termina em `idx`, subindo até o `/*` que o abre. +fn block_doc(lines: &[&str], idx: usize) -> Option { + let mut out: Vec<&str> = Vec::new(); + let mut i = idx; + loop { + let l = lines[i].trim(); + out.push(l); + // `/** … */` numa linha só já está completo aqui. + if l.contains("/*") { + out.reverse(); + return Some(out.join("\n")); } - if l.starts_with("//") { - doc_lines.push(l.to_string()); - found = true; - } else if l.ends_with("*/") { - doc_lines.push(l.to_string()); - // busca o início do bloco - let mut j = idx.checked_sub(1); - while let Some(jdx) = j { - let ll = lines[jdx].trim(); - doc_lines.push(ll.to_string()); - if ll.contains("/*") { - break; - } - j = jdx.checked_sub(1); - } - break; - } else { + // Sem `/*` que abra, o `*/` não pertence a um bloco deste símbolo. + i = i.checked_sub(1)?; + } +} + +/// Sequência contígua de linhas `//` terminando em `idx`. +fn line_doc(lines: &[&str], idx: usize) -> Option { + let mut out: Vec<&str> = Vec::new(); + let mut i = Some(idx); + while let Some(cur) = i { + let l = lines[cur].trim(); + if !l.starts_with("//") { break; } - i = idx.checked_sub(1); + out.push(l); + i = cur.checked_sub(1); } - if doc_lines.is_empty() { - None - } else { - doc_lines.reverse(); - Some(doc_lines.join("\n")) + if out.is_empty() { + return None; } + out.reverse(); + Some(out.join("\n")) } #[allow(clippy::too_many_arguments)] @@ -435,7 +463,10 @@ fn continue_multiline_func( let (pidx, pcol, pname, pparams, pkind, pdep, pdoc) = multiline_func.take().expect("multiline_func presente"); let rest = &line[close_pos + 1..]; - if rest.contains('{') { + // `native` e `forward` declaram sem corpo e terminam em `;` — esperar por + // uma `{` que nunca vem descartaria o símbolo. + let bodyless = matches!(pkind, SymbolKind::Native | SymbolKind::Forward); + if bodyless || rest.contains('{') { let parsed_params = parse_params(&pparams); result.symbols.push(Symbol { signature: Some(format!("{}({})", pname, pparams.trim())), @@ -498,8 +529,10 @@ impl ParserState { /// ao antigo `continue`); ao final, a profundidade de chaves é atualizada. fn process_line(&mut self, raw_line: &str, line_idx: usize, raw_lines: &[&str]) { // `#pragma deprecated` marca o próximo símbolo declarado; a mensagem - // que a segue é repassada no aviso de uso. - if let Some(m) = pragma_deprecated_message(raw_line) { + // que a segue é repassada no aviso de uso. O teste barato vem primeiro: + // extrair a mensagem aloca, e a diretiva é rara em meio ao arquivo. + if is_pragma_deprecated(raw_line) { + let m = pragma_deprecated_message(raw_line).unwrap_or_default(); self.pending_deprecated = Deprecation::marked((!m.is_empty()).then_some(m)); let stripped = strip_line_comments(raw_line, self.in_block); self.in_block = stripped.in_block; @@ -1106,6 +1139,179 @@ mod tests { ); } + #[test] + fn doc_survives_pragma_between_comment_and_decl() { + // A diretiva fica entre o comentário e a declaração; o doc é do símbolo + // que ela marca, não de quem vier antes. + let src = "/**\n * Bane com motivo.\n */\n#pragma deprecated Use BanPlayerFor\nnative BanEx(playerid);"; + let f = parse_file(src); + let sym = f.symbols.iter().find(|s| s.name == "BanEx").unwrap(); + assert!(sym.deprecated); + assert!( + sym.doc + .as_deref() + .is_some_and(|d| d.contains("Bane com motivo")), + "doc: {:?}", + sym.doc + ); + } + + #[test] + fn parses_multiline_native() { + // Assinatura quebrada em várias linhas, como no omp-stdlib. + let src = "native BanEx(\n playerid,\n const reason[]\n);"; + let f = parse_file(src); + let sym = f + .symbols + .iter() + .find(|s| s.name == "BanEx") + .expect("BanEx não encontrada"); + assert_eq!(sym.kind, SymbolKind::Native); + assert_eq!(sym.params.len(), 2); + assert_eq!(sym.params[0].name, "playerid"); + assert_eq!(sym.params[1].name, "reason"); + } + + #[test] + fn parses_multiline_forward() { + let src = "forward OnPlayerBanned(\n playerid,\n Float:duration\n);"; + let f = parse_file(src); + let sym = f + .symbols + .iter() + .find(|s| s.name == "OnPlayerBanned") + .expect("OnPlayerBanned não encontrada"); + assert_eq!(sym.kind, SymbolKind::Forward); + assert_eq!(sym.params[1].tag.as_deref(), Some("Float")); + } + + #[test] + fn multiline_native_keeps_doc_and_deprecation() { + let src = "/**\n * Bane com motivo.\n *\n * @param playerid o jogador\n */\n#pragma deprecated Use BanPlayerFor\nnative BanEx(\n playerid,\n const reason[]\n);"; + let f = parse_file(src); + let sym = f.symbols.iter().find(|s| s.name == "BanEx").unwrap(); + assert!(sym.deprecated); + assert_eq!(sym.deprecated_message.as_deref(), Some("Use BanPlayerFor")); + assert!( + sym.doc + .as_deref() + .is_some_and(|d| d.contains("@param playerid")), + "doc: {:?}", + sym.doc + ); + } + + #[test] + fn multiline_native_with_tagged_return() { + let src = "native bool:IsActorStreamedIn(\n actorid,\n playerid\n);"; + let f = parse_file(src); + assert!( + f.symbols.iter().any(|s| s.name == "IsActorStreamedIn"), + "{:?}", + f.symbols.iter().map(|s| &s.name).collect::>() + ); + } + + // --- extract_doc: um bloco só, o imediatamente acima --------------- + + fn doc_de(src: &str, nome: &str) -> Option { + parse_file(src) + .symbols + .iter() + .find(|s| s.name == nome)? + .doc + .clone() + } + + #[test] + fn bloco_colado_na_declaracao_e_o_doc() { + let src = "/**\n * Bane alguém.\n */\nstock Ban() { return 1; }\n"; + assert_eq!( + doc_de(src, "Ban").as_deref(), + Some("/**\n* Bane alguém.\n*/") + ); + } + + #[test] + fn bloco_de_uma_linha_so() { + let src = "/** Limite antigo. */\n#define MAX_X (50)\n"; + assert_eq!( + doc_de(src, "MAX_X").as_deref(), + Some("/** Limite antigo. */") + ); + } + + #[test] + fn linhas_de_comentario_contiguas_sao_doc() { + let src = "// Devolve o nome.\n// Vazio se o id não existir.\nstock Nome() { return 1; }\n"; + assert_eq!( + doc_de(src, "Nome").as_deref(), + Some("// Devolve o nome.\n// Vazio se o id não existir.") + ); + } + + #[test] + fn so_o_bloco_vizinho_conta() { + // O doc da função acima não pode escorrer para a de baixo. + let src = "/**\n * Doc da primeira.\n */\nstock Primeira() { return 1; }\n\nstock Segunda() { return 1; }\n"; + assert_eq!(doc_de(src, "Segunda"), None); + } + + #[test] + fn linha_em_branco_separa_o_doc_da_declaracao() { + // Com uma linha em branco no meio, o comentário não é do símbolo. + let src = "// Anotação solta.\n\nstock Fn() { return 1; }\n"; + assert_eq!(doc_de(src, "Fn"), None); + } + + #[test] + fn codigo_entre_o_comentario_e_a_declaracao_rompe_o_vinculo() { + let src = + "// Doc da primeira.\nstock Primeira() { return 1; }\nstock Segunda() { return 1; }\n"; + assert_eq!(doc_de(src, "Segunda"), None); + } + + #[test] + fn regua_e_cabecalho_de_secao_nao_escorrem_para_o_simbolo() { + // Este era o modo de falha: a varredura subia o arquivo acumulando + // comentários de outras seções. Agora nem chega lá — só o bloco + // vizinho é lido, e a linha em branco encerra. + let src = "// ------------------------------\n// 9. Outra seção\n// ------------------------------\n\nstock Fn() { return 1; }\n"; + assert_eq!(doc_de(src, "Fn"), None); + + let src = "// --- PP0004: sem corpo -------------\n\nstock Outra(playerid);\n"; + assert_eq!(doc_de(src, "Outra"), None); + } + + #[test] + fn cabecalho_colado_na_declaracao_ainda_e_lido() { + // Sem linha em branco, é adjacente — e a regra vale para todos por + // igual, sem adivinhar a intenção de quem escreveu. + let src = "// --- Seção 9 ---\nstock Fn() { return 1; }\n"; + assert_eq!(doc_de(src, "Fn").as_deref(), Some("// --- Seção 9 ---")); + } + + #[test] + fn pragma_entre_o_doc_e_a_declaracao_nao_rompe() { + let src = "/**\n * Bane alguém.\n */\n#pragma deprecated Use BanPlayerFor\nstock Ban() { return 1; }\n"; + let f = parse_file(src); + let s = f.symbols.iter().find(|s| s.name == "Ban").unwrap(); + assert!(s.deprecated); + assert!(s.doc.as_deref().unwrap().contains("Bane alguém.")); + } + + #[test] + fn fim_de_bloco_sem_abertura_nao_vira_doc() { + // `*/` solto: sem `/*` que abra, não há bloco. + let src = "algo */\n#define MAX_Y (1)\n"; + assert_eq!(doc_de(src, "MAX_Y"), None); + } + + #[test] + fn declaracao_na_primeira_linha_nao_tem_doc() { + assert_eq!(doc_de("stock Fn() { return 1; }\n", "Fn"), None); + } + #[test] fn parses_deprecated() { let src = "#pragma deprecated\nstock OldFunc() {}"; diff --git a/src/server.rs b/src/server.rs index fc574f5..2a20c01 100644 --- a/src/server.rs +++ b/src/server.rs @@ -8,14 +8,14 @@ use tower_lsp::jsonrpc::Result; use tower_lsp::lsp_types::{ CodeAction, CodeActionKind, CodeActionOrCommand, CodeActionParams, CodeActionProviderCapability, CodeActionResponse, CodeLens, CodeLensOptions, CodeLensParams, - CompletionItem, CompletionOptions, CompletionParams, CompletionResponse, Diagnostic, - DiagnosticSeverity, DiagnosticTag, DidChangeConfigurationParams, DidChangeTextDocumentParams, - DidChangeWatchedFilesParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams, - DidSaveTextDocumentParams, DocumentFormattingParams, DocumentRangeFormattingParams, Hover, - HoverParams, HoverProviderCapability, InitializeParams, InitializeResult, InitializedParams, - Location, MessageType, NumberOrString, OneOf, Position, PrepareRenameResponse, Range, - ReferenceParams, RenameOptions, RenameParams, SaveOptions, SemanticTokensFullOptions, - SemanticTokensOptions, SemanticTokensParams, SemanticTokensResult, + CompletionItem, CompletionList, CompletionOptions, CompletionParams, CompletionResponse, + Diagnostic, DiagnosticSeverity, DiagnosticTag, DidChangeConfigurationParams, + DidChangeTextDocumentParams, DidChangeWatchedFilesParams, DidCloseTextDocumentParams, + DidOpenTextDocumentParams, DidSaveTextDocumentParams, DocumentFormattingParams, + DocumentRangeFormattingParams, Hover, HoverParams, HoverProviderCapability, InitializeParams, + InitializeResult, InitializedParams, Location, MessageType, NumberOrString, OneOf, Position, + PrepareRenameResponse, Range, ReferenceParams, RenameOptions, RenameParams, SaveOptions, + SemanticTokensFullOptions, SemanticTokensOptions, SemanticTokensParams, SemanticTokensResult, SemanticTokensServerCapabilities, ServerCapabilities, ServerInfo, SignatureHelp, SignatureHelpOptions, SignatureHelpParams, TextDocumentPositionParams, TextDocumentSyncCapability, TextDocumentSyncKind, TextDocumentSyncOptions, @@ -26,6 +26,7 @@ use tower_lsp::{Client, LanguageServer}; use crate::analyzer::diagnostic::Severity; use crate::intellisense; use crate::messages::Locale; +use crate::util::to_u32; use crate::workspace::{WorkspaceState, uri_to_path}; pub struct PawnProServer { @@ -214,13 +215,24 @@ impl LanguageServer for PawnProServer { let position = params.text_document_position.position; let state = Arc::clone(&self.state); - let items = tokio::task::spawn_blocking(move || { + let mut items = tokio::task::spawn_blocking(move || { intellisense::get_completions(&state.blocking_read(), &uri_str, position) }) .await .unwrap_or_default(); - Ok((!items.is_empty()).then_some(CompletionResponse::Array(items))) + if items.is_empty() { + return Ok(None); + } + // Já vêm ordenados por proximidade do cursor, então o corte descarta os + // menos relevantes; `is_incomplete` faz o editor pedir de novo enquanto + // o prefixo cresce. + let is_incomplete = items.len() > intellisense::MAX_COMPLETION_ITEMS; + items.truncate(intellisense::MAX_COMPLETION_ITEMS); + Ok(Some(CompletionResponse::List(CompletionList { + is_incomplete, + items, + }))) } async fn hover(&self, params: HoverParams) -> Result> { @@ -701,6 +713,10 @@ fn code_actions_for( }; let mut actions: CodeActionResponse = Vec::new(); naming_actions(state, uri, params, &text, &mut actions); + pragma_actions(uri, params, &text, &mut actions); + missing_body_actions(uri, params, &text, &mut actions); + undeclared_actions(state, uri, params, &text, &mut actions); + indent_actions(state, uri, params, &text, &mut actions); removal_actions(uri, params, &text, &mut actions); actions } @@ -731,7 +747,7 @@ fn naming_actions( continue; }; actions.push(CodeActionOrCommand::CodeAction(CodeAction { - title: format!("Renomear \"{name}\" para \"{suggestion}\""), + title: format!("Renomear para \"{suggestion}\""), kind: Some(CodeActionKind::QUICKFIX), diagnostics: Some(vec![diag.clone()]), edit: Some(edit), @@ -742,6 +758,222 @@ fn naming_actions( } } +/// Quick fixes das diretivas `#pragma` malformadas (PP0019): corrigir o nome +/// da diretiva ou tirar as aspas da mensagem de `deprecated`. +fn pragma_actions( + uri: &str, + params: &CodeActionParams, + text: &str, + actions: &mut CodeActionResponse, +) { + use crate::analyzer::pragmas::{PragmaFix, collect_issues}; + + let diags = diagnostics_with_code(params, "PP0019"); + if diags.is_empty() { + return; + } + let issues = collect_issues(text); + for diag in diags { + // Casa pela posição: um arquivo pode ter várias diretivas com problema. + let Some(issue) = issues + .iter() + .find(|i| i.line == diag.range.start.line && i.col == diag.range.start.character) + else { + continue; + }; + let Some(fix) = &issue.fix else { continue }; + let (title, new_text) = match fix { + PragmaFix::Rename(s) => (format!("Usar `#pragma {s}`"), s.clone()), + PragmaFix::Unquote(inner) => ("Remover as aspas".to_string(), inner.clone()), + }; + let range = Range { + start: Position { + line: issue.line, + character: issue.col, + }, + end: Position { + line: issue.line, + character: issue.col_end, + }, + }; + actions.push(CodeActionOrCommand::CodeAction(CodeAction { + title, + kind: Some(CodeActionKind::QUICKFIX), + diagnostics: Some(vec![diag.clone()]), + edit: Some(replacement_edit(uri, range, new_text)), + is_preferred: Some(true), + ..Default::default() + })); + } +} + +/// `WorkspaceEdit` que substitui `range` por `new_text` no arquivo `uri`. +fn replacement_edit(uri: &str, range: Range, new_text: String) -> WorkspaceEdit { + let mut changes = std::collections::HashMap::new(); + if let Ok(parsed) = uri.parse::() { + changes.insert(parsed, vec![TextEdit { range, new_text }]); + } + WorkspaceEdit { + changes: Some(changes), + ..Default::default() + } +} + +/// Quick fixes do PP0004 (`public`/`stock` sem corpo): dar um corpo vazio, ou +/// converter em `forward` — que é a forma de declarar sem corpo. +fn missing_body_actions( + uri: &str, + params: &CodeActionParams, + text: &str, + actions: &mut CodeActionResponse, +) { + let lines: Vec<&str> = text.lines().collect(); + for diag in diagnostics_with_code(params, "PP0004") { + let idx = diag.range.start.line as usize; + let Some(cur) = lines.get(idx) else { continue }; + let trimmed_end = cur.trim_end(); + // Só age na forma canônica `… );` — variações ficam para o usuário. + if !trimmed_end.ends_with(';') { + continue; + } + let semi = trimmed_end.len() - 1; + let line = diag.range.start.line; + + // 1. Trocar o `;` por um corpo vazio. + actions.push(CodeActionOrCommand::CodeAction(CodeAction { + title: "Adicionar corpo vazio".to_string(), + kind: Some(CodeActionKind::QUICKFIX), + diagnostics: Some(vec![diag.clone()]), + edit: Some(replacement_edit( + uri, + Range { + start: Position { + line, + character: to_u32(semi), + }, + end: Position { + line, + character: to_u32(trimmed_end.len()), + }, + }, + "\n{\n}".to_string(), + )), + is_preferred: Some(true), + ..Default::default() + })); + + // 2. Converter em `forward`, que declara sem corpo. + let t = cur.trim_start(); + let indent = cur.len() - t.len(); + if let Some(kw) = ["public ", "stock "].iter().find(|k| t.starts_with(**k)) { + actions.push(CodeActionOrCommand::CodeAction(CodeAction { + title: format!("Converter `{}` em `forward`", kw.trim_end()), + kind: Some(CodeActionKind::QUICKFIX), + diagnostics: Some(vec![diag.clone()]), + edit: Some(replacement_edit( + uri, + Range { + start: Position { + line, + character: to_u32(indent), + }, + end: Position { + line, + character: to_u32(indent + kw.len()), + }, + }, + "forward ".to_string(), + )), + ..Default::default() + })); + } + } +} + +/// Quick fix do PP0010: trocar a chamada por um símbolo conhecido de nome +/// parecido — o caso comum é um erro de digitação. +fn undeclared_actions( + state: &crate::workspace::WorkspaceState, + uri: &str, + params: &CodeActionParams, + text: &str, + actions: &mut CodeActionResponse, +) { + let diags = diagnostics_with_code(params, "PP0010"); + if diags.is_empty() { + return; + } + let Some(file_path) = uri_to_path(uri) else { + return; + }; + let Some(parsed) = state.get_parsed(uri) else { + return; + }; + let inc_paths = state.include_paths(); + + for diag in diags { + let Some(name) = crate::text::word_at(text, diag.range.start) else { + continue; + }; + let Some(suggestion) = + intellisense::suggest_symbol(state, &file_path, &inc_paths, &parsed, &name) + else { + continue; + }; + actions.push(CodeActionOrCommand::CodeAction(CodeAction { + title: format!("Trocar por \"{suggestion}\""), + kind: Some(CodeActionKind::QUICKFIX), + diagnostics: Some(vec![diag.clone()]), + edit: Some(replacement_edit(uri, diag.range, suggestion)), + is_preferred: Some(true), + ..Default::default() + })); + } +} + +/// Quick fix do PP0017: reindenta a linha usando o formatador, com o estilo +/// configurado no workspace — não uma indentação inventada aqui. +fn indent_actions( + state: &crate::workspace::WorkspaceState, + uri: &str, + params: &CodeActionParams, + text: &str, + actions: &mut CodeActionResponse, +) { + for diag in diagnostics_with_code(params, "PP0017") { + let line = diag.range.start.line; + let range = Range { + start: Position { line, character: 0 }, + end: Position { + line: line + 1, + character: 0, + }, + }; + // `format_range` aplica o preset e os overrides do workspace; o + // `#pragma tabsize` do projeto já está refletido em `state.format_style`. + let edits = intellisense::format_range(text, range, state.format_style); + if edits.is_empty() { + continue; + } + let mut changes = std::collections::HashMap::new(); + let Ok(parsed) = uri.parse::() else { + continue; + }; + changes.insert(parsed, edits); + actions.push(CodeActionOrCommand::CodeAction(CodeAction { + title: "Corrigir a indentação".to_string(), + kind: Some(CodeActionKind::QUICKFIX), + diagnostics: Some(vec![diag.clone()]), + edit: Some(WorkspaceEdit { + changes: Some(changes), + ..Default::default() + }), + is_preferred: Some(true), + ..Default::default() + })); + } +} + /// Quick fixes de remoção de código não usado, a partir dos diagnósticos nos /// params. Não oferecido em arquivos `.inc` — onde um símbolo "não usado" pode /// ser usado por quem consome a include (falso positivo ao desenvolvê-la). @@ -751,9 +983,7 @@ fn removal_actions( text: &str, actions: &mut CodeActionResponse, ) { - if uri.to_ascii_lowercase().ends_with(".inc") { - return; - } + let is_inc = uri.to_ascii_lowercase().ends_with(".inc"); for diag in ¶ms.context.diagnostics { let Some(NumberOrString::String(code)) = &diag.code else { continue; @@ -761,6 +991,11 @@ fn removal_actions( let Some(kind) = intellisense::removal_kind(code) else { continue; }; + // Em `.inc`, "não usado" é falso positivo (quem consome a include usa). + // Corpo ilegal, porém, é erro de sintaxe em qualquer arquivo. + if is_inc && kind != intellisense::RemovalKind::IllegalBody { + continue; + } let line = diag.range.start.line; let col = diag.range.start.character; let Some(range) = intellisense::removal_range(text, line, col, kind) else { @@ -811,6 +1046,9 @@ fn removal_title(code: &str) -> String { match code { "PP0009" => "Remover parâmetro não usado".to_string(), "PP0005" => "Remover variável não usada".to_string(), + "PP0011" => "Remover #define não usado".to_string(), + "PP0012" => "Remover #include não usado".to_string(), + "PP0002" | "PP0003" => "Remover o corpo".to_string(), _ => "Remover declaração não usada".to_string(), } } diff --git a/src/similar.rs b/src/similar.rs new file mode 100644 index 0000000..b960dbd --- /dev/null +++ b/src/similar.rs @@ -0,0 +1,130 @@ +//! Sugestão de "você quis dizer …?" por distância de edição. +//! +//! Usada onde um nome escrito à mão pode ter um erro de digitação: diretivas +//! `#pragma` e chamadas a funções não declaradas. + +/// Distância de Levenshtein entre `a` e `b`, abandonando acima de `max`. +/// +/// O corte não é só otimização: acima dele a "sugestão" deixaria de ser +/// plausível e viraria chute. +#[must_use] +pub fn edit_distance_within(a: &str, b: &str, max: usize) -> Option { + let (a, b): (Vec, Vec) = (a.chars().collect(), b.chars().collect()); + if a.len().abs_diff(b.len()) > max { + return None; + } + let mut prev: Vec = (0..=b.len()).collect(); + let mut cur = vec![0usize; b.len() + 1]; + for (i, ca) in a.iter().enumerate() { + cur[0] = i + 1; + for (j, cb) in b.iter().enumerate() { + let cost = usize::from(ca != cb); + cur[j + 1] = (prev[j] + cost).min(cur[j] + 1).min(prev[j + 1] + 1); + } + std::mem::swap(&mut prev, &mut cur); + } + let d = prev[b.len()]; + (d <= max).then_some(d) +} + +/// Tolerância proporcional ao tamanho do nome. +/// +/// Um `max` fixo trata mal os extremos: com 2, qualquer palavra de quatro +/// letras vira sugestão para `pack`; com 1, um erro em `GetPlayerHealth` não é +/// alcançado. +#[must_use] +pub fn tolerance_for(name: &str) -> usize { + match name.chars().count() { + 0..=4 => 1, + 5..=8 => 2, + _ => 3, + } +} + +/// O candidato mais próximo de `name`, quando há um plausível. +/// +/// Compara sem diferenciar maiúsculas — trocar a caixa é justamente um dos +/// erros que se quer pegar. Empates vão para o nome mais curto, e depois para a +/// ordem alfabética, para que a sugestão seja estável entre execuções. +pub fn closest<'a, I>(name: &str, candidates: I) -> Option<&'a str> +where + I: IntoIterator, +{ + let lower = name.to_ascii_lowercase(); + let max = tolerance_for(&lower); + candidates + .into_iter() + .filter(|c| !c.is_empty()) + .filter_map(|c| { + let cl = c.to_ascii_lowercase(); + // Distância 0 é o próprio nome (só a caixa difere): não é sugestão. + edit_distance_within(&lower, &cl, max) + .filter(|d| *d > 0) + .map(|d| (d, c)) + }) + .min_by_key(|(d, c)| (*d, c.len(), *c)) + .map(|(_, c)| c) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn distance_counts_edits() { + assert_eq!(edit_distance_within("abc", "abc", 2), Some(0)); + assert_eq!(edit_distance_within("abc", "abd", 2), Some(1)); + assert_eq!(edit_distance_within("abc", "axd", 2), Some(2)); + assert_eq!(edit_distance_within("abc", "xyz", 2), None); + } + + #[test] + fn length_gap_beyond_max_is_rejected_early() { + assert_eq!(edit_distance_within("a", "abcdef", 2), None); + } + + #[test] + fn tolerance_grows_with_the_name() { + assert_eq!(tolerance_for("pack"), 1); + assert_eq!(tolerance_for("deprecated"), 3); + } + + #[test] + fn closest_finds_the_typo() { + let known = ["SendClientMessage", "SetPlayerHealth", "GetPlayerHealth"]; + assert_eq!( + closest("SendClientMesage", known), + Some("SendClientMessage") + ); + assert_eq!(closest("GetPlayerHelth", known), Some("GetPlayerHealth")); + } + + #[test] + fn closest_ignores_an_exact_match() { + // Já existe: não há o que sugerir. + assert_eq!(closest("SetPlayerHealth", ["SetPlayerHealth"]), None); + } + + #[test] + fn closest_catches_a_case_difference() { + // Só a caixa difere: é o mesmo nome, não há o que sugerir. + assert_eq!(closest("setplayerhealth", ["SetPlayerHealth"]), None); + assert_eq!(closest("SetplayerHealth", ["SetPlayerHealth"]), None); + // Caixa trocada E um erro: aí é sugestão. + assert_eq!( + closest("SetplayerHelth", ["SetPlayerHealth"]), + Some("SetPlayerHealth") + ); + } + + #[test] + fn closest_gives_up_when_nothing_is_near() { + assert_eq!(closest("Xyzzy", ["SendClientMessage"]), None); + } + + #[test] + fn ties_are_resolved_stably() { + // Mesma distância: vence o mais curto, depois a ordem alfabética. + assert_eq!(closest("aa", ["ab", "ac"]), Some("ab")); + } +} diff --git a/src/workspace.rs b/src/workspace.rs index 2d94f36..6f68d45 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -7,7 +7,7 @@ use dashmap::DashMap; use crate::analyzer::PawnDiagnostic; use crate::analyzer::includes::collect_included_files; use crate::analyzer::{ - deprecated, hints, includes, indentation, naming, semantic, undefined, unused, + deprecated, hints, includes, indentation, naming, pragmas, semantic, undefined, unused, }; use crate::config::EngineConfig; use crate::messages::Locale; @@ -228,6 +228,7 @@ impl WorkspaceState { diags.extend(deprecated::analyze_deprecated( &text, &file_path, &parsed, &inc_paths, &resolved, locale, )); + diags.extend(pragmas::analyze_pragmas(&text, locale)); diags.extend(hints::analyze_hints(&text, &parsed.symbols, locale)); diags.extend(undefined::analyze_undefined( &text,