From b571690db7ac1a627feccfc2f871de031d786328 Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Tue, 1 Sep 2026 03:29:19 -0300 Subject: [PATCH 01/19] =?UTF-8?q?fix:=20`native`/`forward`=20com=20assinat?= =?UTF-8?q?ura=20multilinha=20e=20doc=20ap=C3=B3s=20`#pragma`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dois defeitos que se somavam justamente nos includes do open.mp, onde assinaturas longas quebradas em várias linhas são comuns. **Assinatura multilinha descartada.** Ao fechar o `)`, o parser só criava o símbolo se o resto da linha trouxesse `{`; sem isso, guardava em `pending_plain` à espera de um corpo na linha seguinte. `native` e `forward` declaram sem corpo e terminam em `;`, então a espera nunca terminava e o símbolo se perdia — sem hover, sem autocomplete, sem signature help. Um `ApplyActorAnimation` do omp_actor, com nove parâmetros em nove linhas, era invisível para a engine. **Doc perdido quando a diretiva estava no meio.** `extract_doc` caminha para cima a partir da declaração e para na primeira linha que não é comentário — `#pragma deprecated` entre o bloco e a declaração cortava o caminho. Passa a pular a diretiva. Regressão da mudança anterior, que trouxe o `#pragma`: com `@DEPRECATED` o marcador era um comentário e não interrompia a varredura. --- src/parser/symbols.rs | 84 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 83 insertions(+), 1 deletion(-) diff --git a/src/parser/symbols.rs b/src/parser/symbols.rs index 896e640..f4c7c9b 100644 --- a/src/parser/symbols.rs +++ b/src/parser/symbols.rs @@ -260,6 +260,12 @@ fn extract_doc(lines: &[&str], line_idx: usize) -> Option { i = idx.checked_sub(1); continue; } + // `#pragma deprecated` fica entre o comentário e a declaração; pular a + // diretiva mantém o doc ligado ao símbolo que ela marca. + if pragma_deprecated_message(l).is_some() && !found { + i = idx.checked_sub(1); + continue; + } if l.starts_with("//") { doc_lines.push(l.to_string()); found = true; @@ -435,7 +441,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())), @@ -1106,6 +1115,79 @@ 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::>() + ); + } + #[test] fn parses_deprecated() { let src = "#pragma deprecated\nstock OldFunc() {}"; From 56480f064cd24cca0fe6b2926e922bac112661d8 Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Tue, 1 Sep 2026 03:29:37 -0300 Subject: [PATCH 02/19] =?UTF-8?q?docs:=20changelog=20das=20corre=C3=A7?= =?UTF-8?q?=C3=B5es=20de=20parsing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0eadc63..f40f4ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,18 @@ caso encontre por favor relate para ajudar a manter a consistência dos dados. dependência, reduzindo o ruído de manutenção ### Corrigido +- **`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 From 2d879638d244d93d732fa0331bc6636dd81c8372 Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Tue, 1 Sep 2026 03:50:56 -0300 Subject: [PATCH 03/19] perf: evita alocar por linha ao procurar `#pragma deprecated` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pragma_deprecated_message` monta uma `String` e era chamada em toda linha do arquivo — tanto no laço principal quanto na varredura para cima do `extract_doc` — só para responder se a linha é a diretiva. Separa o reconhecimento (`is_pragma_deprecated`, sem alocação) da extração da mensagem, e faz o `extract_doc` acumular fatias em vez de `String` por linha. Num arquivo com 600 nativas documentadas, ~2,5 ms → ~2,1 ms; com a diretiva e sem doc, ~2,1 ms → ~1,96 ms. Medido com 20 repetições após aquecimento — medições únicas variam demais para servir de base. --- src/parser/lexer.rs | 22 ++++++++++++++++++---- src/parser/symbols.rs | 23 +++++++++++++++-------- 2 files changed, 33 insertions(+), 12 deletions(-) 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 f4c7c9b..e6b7734 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; @@ -245,7 +247,10 @@ fn parse_params(raw: &str) -> Vec { } fn extract_doc(lines: &[&str], line_idx: usize) -> Option { - let mut doc_lines = Vec::new(); + // Guarda as fatias e só materializa a `String` no fim: um bloco de doc tem + // várias linhas, e uma alocação por linha aparece no perfil de um include + // grande. + let mut doc_lines: Vec<&str> = 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 @@ -262,20 +267,20 @@ fn extract_doc(lines: &[&str], line_idx: usize) -> Option { } // `#pragma deprecated` fica entre o comentário e a declaração; pular a // diretiva mantém o doc ligado ao símbolo que ela marca. - if pragma_deprecated_message(l).is_some() && !found { + if is_pragma_deprecated(l) && !found { i = idx.checked_sub(1); continue; } if l.starts_with("//") { - doc_lines.push(l.to_string()); + doc_lines.push(l); found = true; } else if l.ends_with("*/") { - doc_lines.push(l.to_string()); + doc_lines.push(l); // 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()); + doc_lines.push(ll); if ll.contains("/*") { break; } @@ -507,8 +512,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; From 79d8efff4b831784a3d46239ca7dfe1ce6811edd Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Tue, 1 Sep 2026 03:50:57 -0300 Subject: [PATCH 04/19] feat: PP0019 valida `#pragma`, com quick fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O compilador rejeita uma diretiva que não conheça (erro 207), mas só na compilação. O diagnóstico antecipa isso e oferece a correção. Dois casos, ambos com quick fix: - **nome não reconhecido** — comparado com a lista de `sc2.c`; havendo uma diretiva próxima (distância de edição pequena, mais estrita para nomes curtos), ela é sugerida; - **mensagem de `deprecated` entre aspas** — a diretiva toma o resto da linha como texto livre (`strdupwithouta` sobre `lptr`, em `sc2.c:1240`), então as aspas entrariam na mensagem em vez de delimitá-la. Aspas no meio do texto são literais legítimas e não são sinalizadas. A forma correta, confirmada no compilador, é sem aspas: #pragma deprecated Use BanPlayerFor --- CHANGELOG.md | 8 ++ docs/diagnostics.md | 19 +++ src/analyzer/codes.rs | 1 + src/analyzer/mod.rs | 1 + src/analyzer/pragmas.rs | 272 ++++++++++++++++++++++++++++++++++++ src/messages/langs/en.rs | 5 + src/messages/langs/es.rs | 7 + src/messages/langs/pt_br.rs | 7 + src/messages/langs/ro.rs | 7 + src/messages/langs/ru.rs | 5 + src/messages/mod.rs | 6 + src/server.rs | 64 +++++++++ src/workspace.rs | 3 +- 13 files changed, 404 insertions(+), 1 deletion(-) create mode 100644 src/analyzer/pragmas.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index f40f4ec..e710c65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,14 @@ 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 +- **`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 - **`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 diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 433db1f..485b5ff 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -24,6 +24,25 @@ 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*. > ¹ Marcados com `DiagnosticTag::UNNECESSARY` — o editor exibe o símbolo desbotado além do sublinhado diagnóstico. 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..865a832 --- /dev/null +++ b/src/analyzer/pragmas.rs @@ -0,0 +1,272 @@ +//! 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::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), +} + +/// Distância de edição, limitada a `max`: acima disso a sugestão viraria chute. +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) +} + +/// A diretiva conhecida mais próxima de `word`, quando há uma plausível. +fn closest_known(word: &str) -> Option<&'static str> { + let lower = word.to_ascii_lowercase(); + // Um nome curto tolera menos erro: com `max` fixo, "pack" viraria sugestão + // para qualquer palavra de quatro letras. + let max = if lower.len() <= 4 { 1 } else { 2 }; + KNOWN + .iter() + .filter_map(|k| edit_distance_within(&lower, k, max).map(|d| (d, *k))) + .min_by_key(|(d, k)| (*d, k.len())) + .map(|(_, k)| k) +} + +/// 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_known(&word); + 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/messages/langs/en.rs b/src/messages/langs/en.rs index e64a345..5ce65c7 100644 --- a/src/messages/langs/en.rs +++ b/src/messages/langs/en.rs @@ -17,6 +17,11 @@ pub fn get(key: MsgKey) -> &'static str { MsgKey::StockUnused => "\"{}\" stock function declared but never used", MsgKey::SymDeprecated => "\"{}\" is marked as deprecated", MsgKey::SymDeprecatedUsage => "\"{}\" is deprecated", + MsgKey::PragmaUnknown => "`#pragma {}` is not recognized by the compiler", + MsgKey::PragmaUnknownDidYouMean => "`#pragma {}` is not recognized — did you mean `{sug}`?", + MsgKey::PragmaDeprecatedQuoted => { + "`#pragma deprecated` takes the rest of the line as text — the quotes would end up in the message" + } 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..bca837d 100644 --- a/src/messages/langs/es.rs +++ b/src/messages/langs/es.rs @@ -22,6 +22,13 @@ pub fn get(key: MsgKey) -> &'static str { MsgKey::StockUnused => "función stock \"{}\" declarada pero nunca usada", MsgKey::SymDeprecated => "\"{}\" está marcado como obsoleto", MsgKey::SymDeprecatedUsage => "\"{}\" está obsoleto", + MsgKey::PragmaUnknown => "`#pragma {}` no es reconocido por el compilador", + MsgKey::PragmaUnknownDidYouMean => { + "`#pragma {}` no es reconocido — ¿querías decir `{sug}`?" + } + MsgKey::PragmaDeprecatedQuoted => { + "`#pragma deprecated` toma el resto de la línea como texto — las comillas quedarían en el mensaje" + } 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..9c13f99 100644 --- a/src/messages/langs/pt_br.rs +++ b/src/messages/langs/pt_br.rs @@ -17,6 +17,13 @@ pub fn get(key: MsgKey) -> &'static str { MsgKey::StockUnused => "\"{}\" função stock declarada mas não utilizada", MsgKey::SymDeprecated => "\"{}\" está marcado como depreciado", MsgKey::SymDeprecatedUsage => "\"{}\" está depreciado", + MsgKey::PragmaUnknown => "`#pragma {}` não é reconhecido pelo compilador", + MsgKey::PragmaUnknownDidYouMean => { + "`#pragma {}` não é reconhecido — você quis dizer `{sug}`?" + } + MsgKey::PragmaDeprecatedQuoted => { + "`#pragma deprecated` toma o resto da linha como texto — as aspas entrariam na mensagem" + } 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..0f7c41a 100644 --- a/src/messages/langs/ro.rs +++ b/src/messages/langs/ro.rs @@ -22,6 +22,13 @@ pub fn get(key: MsgKey) -> &'static str { MsgKey::StockUnused => "funcția stock \"{}\" declarată dar niciodată folosită", MsgKey::SymDeprecated => "\"{}\" este marcat ca învechit", MsgKey::SymDeprecatedUsage => "\"{}\" este învechit", + MsgKey::PragmaUnknown => "`#pragma {}` nu este recunoscut de compilator", + MsgKey::PragmaUnknownDidYouMean => { + "`#pragma {}` nu este recunoscut — ai vrut să spui `{sug}`?" + } + MsgKey::PragmaDeprecatedQuoted => { + "`#pragma deprecated` ia restul liniei ca text — ghilimelele ar ajunge în mesaj" + } 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..f2084fa 100644 --- a/src/messages/langs/ru.rs +++ b/src/messages/langs/ru.rs @@ -22,6 +22,11 @@ pub fn get(key: MsgKey) -> &'static str { MsgKey::StockUnused => "stock-функция \"{}\" объявлена, но не используется", MsgKey::SymDeprecated => "\"{}\" помечен как устаревший", MsgKey::SymDeprecatedUsage => "\"{}\" устарел", + MsgKey::PragmaUnknown => "`#pragma {}` не распознан компилятором", + MsgKey::PragmaUnknownDidYouMean => "`#pragma {}` не распознан — возможно, `{sug}`?", + MsgKey::PragmaDeprecatedQuoted => { + "`#pragma deprecated` берёт остаток строки как текст — кавычки попадут в сообщение" + } 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/server.rs b/src/server.rs index fc574f5..aaa5351 100644 --- a/src/server.rs +++ b/src/server.rs @@ -701,6 +701,7 @@ 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); removal_actions(uri, params, &text, &mut actions); actions } @@ -742,6 +743,69 @@ 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!("Trocar para `#pragma {s}`"), s.clone()), + PragmaFix::Unquote(inner) => { + ("Remover as aspas da mensagem".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 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). 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, From d69efe1ca3213bf1ca737cc949fa2c027120bb3a Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Tue, 1 Sep 2026 04:05:50 -0300 Subject: [PATCH 05/19] feat: quick fix para PP0002/0003/0004/0010/0011/0012/0017 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Estende as correções rápidas aos diagnósticos cuja correção é determinística: - `PP0002`/`PP0003` — remove o corpo `{ … }` que o compilador não aceita em `native`/`forward`. Por ser erro de sintaxe, é oferecido também em `.inc`, ao contrário das remoções por "não utilizado"; - `PP0004` — duas ações: dar um corpo vazio, ou converter `public`/`stock` em `forward`, que é a forma de declarar sem corpo; - `PP0010` — troca por um símbolo conhecido de nome parecido, entre os do arquivo e de todos os includes transitivos. É o mesmo universo do autocomplete, então a sugestão nunca aponta para algo invisível ao arquivo; - `PP0011`/`PP0012` — remove a linha da diretiva, arrastando continuações com `\` no fim; - `PP0017` — reindenta a linha pelo formatador, com o estilo configurado no workspace (preset e overrides), em vez de uma indentação fixa. `PP0014`/`PP0015` ficam de fora de propósito: remover uma `native` ou um `forward` de uma include quebraria quem a consome. A distância de edição que o PP0019 usava sai de `pragmas.rs` para `similar.rs`, agora compartilhada com o PP0010, com tolerância proporcional ao tamanho do nome — um `max` fixo trata mal os extremos. --- CHANGELOG.md | 9 ++ docs/diagnostics.md | 19 ++++ src/analyzer/pragmas.rs | 36 +------- src/intellisense/mod.rs | 19 +++- src/intellisense/quickfix.rs | 127 ++++++++++++++++++++++++++ src/main.rs | 1 + src/server.rs | 171 ++++++++++++++++++++++++++++++++++- src/similar.rs | 130 ++++++++++++++++++++++++++ 8 files changed, 474 insertions(+), 38 deletions(-) create mode 100644 src/similar.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index e710c65..ad42496 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,15 @@ 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 +- **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 diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 485b5ff..09d0c5f 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -44,6 +44,25 @@ Cobre dois casos: 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. ## Detalhes diff --git a/src/analyzer/pragmas.rs b/src/analyzer/pragmas.rs index 865a832..2b2be0f 100644 --- a/src/analyzer/pragmas.rs +++ b/src/analyzer/pragmas.rs @@ -7,6 +7,7 @@ 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`). @@ -47,39 +48,6 @@ pub enum PragmaFix { Unquote(String), } -/// Distância de edição, limitada a `max`: acima disso a sugestão viraria chute. -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) -} - -/// A diretiva conhecida mais próxima de `word`, quando há uma plausível. -fn closest_known(word: &str) -> Option<&'static str> { - let lower = word.to_ascii_lowercase(); - // Um nome curto tolera menos erro: com `max` fixo, "pack" viraria sugestão - // para qualquer palavra de quatro letras. - let max = if lower.len() <= 4 { 1 } else { 2 }; - KNOWN - .iter() - .filter_map(|k| edit_distance_within(&lower, k, max).map(|d| (d, *k))) - .min_by_key(|(d, k)| (*d, k.len())) - .map(|(_, k)| k) -} - /// Uma diretiva `#pragma` malformada, já com a correção sugerida. pub struct PragmaIssue { pub line: u32, @@ -155,7 +123,7 @@ pub fn collect_issues(text: &str) -> Vec { } if !KNOWN.contains(&word.to_ascii_lowercase().as_str()) { - let suggestion = closest_known(&word); + let suggestion = closest(&word, KNOWN.iter().copied()); out.push(PragmaIssue { line: to_u32(idx), col: to_u32(name_col), diff --git a/src/intellisense/mod.rs b/src/intellisense/mod.rs index 15dea55..73713fe 100644 --- a/src/intellisense/mod.rs +++ b/src/intellisense/mod.rs @@ -18,7 +18,7 @@ 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.into_iter()).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/server.rs b/src/server.rs index aaa5351..57b4cdf 100644 --- a/src/server.rs +++ b/src/server.rs @@ -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 { @@ -702,6 +703,9 @@ 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 } @@ -806,6 +810,161 @@ fn replacement_edit(uri: &str, range: Range, new_text: String) -> WorkspaceEdit } } +/// 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 desta linha".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). @@ -815,9 +974,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; @@ -825,6 +982,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 { @@ -875,6 +1037,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 da declaração".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")); + } +} From 4afc4ffc82cdb473c2eb229f8af65a695e26e407 Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Tue, 1 Sep 2026 04:10:10 -0300 Subject: [PATCH 06/19] feat: ordena o autocomplete pela proximidade do cursor e limita a lista MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O `sort_text` punha os símbolos globais em `0_` e as variáveis locais em `1_`: dentro de uma função, os parâmetros e as locais apareciam **abaixo** de milhares de nativas dos includes — o inverso do útil. A ordem passa a ser explícita, num `Rank`: locais e parâmetros, símbolos do próprio arquivo, símbolos dos includes, palavras-chave, e por último os marcados com `#pragma deprecated`. Um descontinuado continua na lista, mas nunca à frente de uma alternativa viva. Dentro do grupo, a ordem é alfabética sem diferenciar caixa — sem isso "Zebra" viria antes de "alfa". A lista também era enviada inteira a cada tecla. Passa a ser cortada em 1000 itens com `isIncomplete`, o mecanismo do LSP para listas grandes: o editor pede de novo conforme o prefixo cresce. A ordenação acontece antes do corte, então o que se descarta são os itens mais distantes do cursor. --- CHANGELOG.md | 13 +++ docs/lsp.md | 14 ++- src/intellisense/completion.rs | 151 +++++++++++++++++++++++++++++++-- src/intellisense/mod.rs | 2 +- src/server.rs | 31 ++++--- 5 files changed, 194 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad42496..bfcb134 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,19 @@ 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`); 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/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/mod.rs b/src/intellisense/mod.rs index 73713fe..5d0b273 100644 --- a/src/intellisense/mod.rs +++ b/src/intellisense/mod.rs @@ -13,7 +13,7 @@ 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}; diff --git a/src/server.rs b/src/server.rs index 57b4cdf..89d7be9 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, @@ -215,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> { From 976ca04342dcba463eb81adc22b58786e9276225 Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Tue, 1 Sep 2026 04:13:51 -0300 Subject: [PATCH 07/19] fix: remove `.into_iter()` redundante (clippy useless_conversion) --- src/intellisense/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/intellisense/mod.rs b/src/intellisense/mod.rs index 5d0b273..2dbedea 100644 --- a/src/intellisense/mod.rs +++ b/src/intellisense/mod.rs @@ -72,7 +72,7 @@ pub fn suggest_symbol( ) -> 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.into_iter()).map(str::to_string) + crate::similar::closest(name, names).map(str::to_string) } pub(crate) fn collect_all_symbols( From 8a427ce9c1dc42954e5a34e155a8d18cbab279a7 Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Tue, 1 Sep 2026 04:30:15 -0300 Subject: [PATCH 08/19] =?UTF-8?q?fix:=20doc=20comment=20vazando=20entre=20?= =?UTF-8?q?s=C3=ADmbolos=20e=20hover=20desalinhado?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Doc de um símbolo no hover de outro.** Ao ver uma linha terminada em `*/`, `extract_doc` empurrava a linha e procurava o `/*` de abertura a partir da **anterior** — mas um bloco de uma linha (`/** … */`) já está completo. A busca então atravessava o código acima até casar com o `/**` de outro comentário, e o hover de um `#define` saía com a documentação da função anterior mais o código do meio. Um bloco de uma linha passa a encerrar ali mesmo, e um `*/` sem abertura devolve `None` em vez de arrastar o arquivo até o topo. **Hover desalinhado.** O aviso de depreciado era um blockquote (`> …`): o editor recuava o bloco e lia o `---` seguinte como continuação, desalinhando a documentação. Vira texto normal, e passa a mostrar a mensagem do `#pragma deprecated` — que até então só aparecia no aviso de uso. --- CHANGELOG.md | 12 +++++++ src/intellisense/hover.rs | 72 ++++++++++++++++++++++++++++++++++++++- src/parser/symbols.rs | 48 +++++++++++++++++++++----- 3 files changed, 123 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfcb134..92da85a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -111,6 +111,18 @@ caso encontre por favor relate para ajudar a manter a consistência dos dados. de delimitá-la. Aspas no meio do texto continuam sendo texto legítimo ### Corrigido +- **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 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/parser/symbols.rs b/src/parser/symbols.rs index e6b7734..847a977 100644 --- a/src/parser/symbols.rs +++ b/src/parser/symbols.rs @@ -276,15 +276,26 @@ fn extract_doc(lines: &[&str], line_idx: usize) -> Option { found = true; } else if l.ends_with("*/") { doc_lines.push(l); - // 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); - if ll.contains("/*") { - break; + // Um bloco de uma linha só (`/** … */`) já está completo; procurar + // o início a partir da linha anterior atravessaria o código acima + // até casar com o `/*` de outro comentário. + if !l.starts_with("/*") { + let mut j = idx.checked_sub(1); + let mut open_found = false; + while let Some(jdx) = j { + let ll = lines[jdx].trim(); + doc_lines.push(ll); + if ll.contains("/*") { + open_found = true; + break; + } + j = jdx.checked_sub(1); + } + // Sem `/*` que abra, o `*/` não pertence a um bloco de doc + // deste símbolo: descarta em vez de arrastar o arquivo inteiro. + if !open_found { + return None; } - j = jdx.checked_sub(1); } break; } else { @@ -1195,6 +1206,27 @@ mod tests { ); } + #[test] + fn doc_de_bloco_de_uma_linha_nao_atravessa_o_codigo_acima() { + // Um `/** … */` de uma linha já está completo: procurar o `/*` de + // abertura acima faria a varredura engolir funções inteiras até casar + // com o `/**` de outro comentário. + let src = "/**\n * Doc da funcao.\n */\nstock Antiga() { return 1; }\n\n/** Limite antigo. */\n#define MAX_X (50)\n"; + let f = parse_file(src); + let d = f.symbols.iter().find(|s| s.name == "MAX_X").unwrap(); + assert_eq!(d.doc.as_deref(), Some("/** Limite antigo. */")); + } + + #[test] + fn fim_de_bloco_sem_abertura_nao_vira_doc() { + // `*/` solto acima da declaração: sem `/*` que abra, não é doc deste + // símbolo — arrastar o arquivo até o topo seria pior que não ter doc. + let src = "stock Outra() { return 1; }\nalgo */\n#define MAX_Y (1)\n"; + let f = parse_file(src); + let d = f.symbols.iter().find(|s| s.name == "MAX_Y").unwrap(); + assert_eq!(d.doc, None); + } + #[test] fn parses_deprecated() { let src = "#pragma deprecated\nstock OldFunc() {}"; From 814e7b6e968e5125cd26447bcba99256e5abea8d Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Tue, 1 Sep 2026 04:40:54 -0300 Subject: [PATCH 09/19] =?UTF-8?q?fix:=20r=C3=A9gua=20de=20separa=C3=A7?= =?UTF-8?q?=C3=A3o=20deixa=20de=20ser=20lida=20como=20doc=20comment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Um cabeçalho de seção acima de uma função era tratado como documentação dela, e o hover mostrava a régua mais o texto da seção: // --------------------------------------------------- // 9. Símbolos vindos dos includes // --------------------------------------------------- stock TesteIncludes(playerid) // hover exibia o bloco acima Uma linha de comentário composta só de ornamento (`-`, `=`, `*`, `_`, `#`, `~`) encerra a varredura: além de não documentar nada, o que está acima dela pertence a outra seção do arquivo. Comentários `//` com texto seguem valendo como documentação — é convenção legítima em Pawn, e só a régua sai. --- CHANGELOG.md | 6 ++++ src/parser/symbols.rs | 67 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92da85a..b771f53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -111,6 +111,12 @@ caso encontre por favor relate para ajudar a manter a consistência dos dados. de delimitá-la. Aspas no meio do texto continuam sendo texto legítimo ### Corrigido +- **Réguas de separação viravam documentação** — um cabeçalho de seção + (`// ------------`) acima de uma função era lido como doc comment dela, e o + hover exibia a régua e o texto da seção no lugar da documentação. Uma linha + de comentário composta só de `-`, `=`, `*`, `_`, `#` ou `~` passa a encerrar + a varredura: é ornamento, e o que está acima dela pertence a outra seção. + Comentários `//` com texto continuam sendo documentação, como antes - **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**, diff --git a/src/parser/symbols.rs b/src/parser/symbols.rs index 847a977..7135aa3 100644 --- a/src/parser/symbols.rs +++ b/src/parser/symbols.rs @@ -246,6 +246,16 @@ fn parse_params(raw: &str) -> Vec { params } +/// `true` se o comentário é só uma régua de separação (`// ------`, `// =====`), +/// sem texto — ornamento de seção, não documentação. +fn is_comment_rule(line: &str) -> bool { + let body = line.trim_start_matches('/').trim(); + !body.is_empty() + && body + .chars() + .all(|c| matches!(c, '-' | '=' | '*' | '_' | '#' | '~')) +} + fn extract_doc(lines: &[&str], line_idx: usize) -> Option { // Guarda as fatias e só materializa a `String` no fim: um bloco de doc tem // várias linhas, e uma alocação por linha aparece no perfil de um include @@ -272,6 +282,11 @@ fn extract_doc(lines: &[&str], line_idx: usize) -> Option { continue; } if l.starts_with("//") { + // Uma régua (`// -----`) separa seções do arquivo; não documenta o + // símbolo abaixo, e tudo acima dela pertence a outra seção. + if is_comment_rule(l) { + break; + } doc_lines.push(l); found = true; } else if l.ends_with("*/") { @@ -1227,6 +1242,58 @@ mod tests { assert_eq!(d.doc, None); } + #[test] + fn regua_de_separacao_nao_e_doc() { + // Uma régua separa seções do arquivo; nem ela nem o que vem acima + // documentam o símbolo abaixo. + let src = "// -----------------\n// 9. Outra seção\n// -----------------\n\nstock Fn() { return 1; }\n"; + let f = parse_file(src); + assert_eq!(f.symbols.iter().find(|s| s.name == "Fn").unwrap().doc, None); + } + + #[test] + fn comentario_de_linha_continua_sendo_doc() { + // `//` acima da declaração é convenção legítima em Pawn — só a régua sai. + let src = "// Devolve o nome do jogador.\nstock Nome() { return 1; }\n"; + let f = parse_file(src); + let doc = f + .symbols + .iter() + .find(|s| s.name == "Nome") + .unwrap() + .doc + .clone(); + assert_eq!(doc.as_deref(), Some("// Devolve o nome do jogador.")); + } + + #[test] + fn doc_apos_uma_regua_e_preservado() { + // A régua encerra a varredura, mas o que está entre ela e a declaração + // é documentação de verdade. + let src = "// =========\n// Bane alguém.\nstock Ban() { return 1; }\n"; + let f = parse_file(src); + let doc = f + .symbols + .iter() + .find(|s| s.name == "Ban") + .unwrap() + .doc + .clone(); + assert_eq!(doc.as_deref(), Some("// Bane alguém.")); + } + + #[test] + fn regua_reconhece_varios_ornamentos() { + assert!(is_comment_rule("// -----")); + assert!(is_comment_rule("//=====")); + assert!(is_comment_rule("// ***")); + assert!(is_comment_rule("// ~~~~~")); + // Com texto, não é régua. + assert!(!is_comment_rule("// --- Seção 9 ---")); + assert!(!is_comment_rule("// Bane alguém.")); + assert!(!is_comment_rule("//")); + } + #[test] fn parses_deprecated() { let src = "#pragma deprecated\nstock OldFunc() {}"; From df69d9bdcc8ca890d59906f7b61b6ca065ef4cb7 Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Tue, 1 Sep 2026 04:43:35 -0300 Subject: [PATCH 10/19] =?UTF-8?q?style:=20encurta=20as=20mensagens=20de=20?= =?UTF-8?q?aviso=20e=20os=20t=C3=ADtulos=20das=20corre=C3=A7=C3=B5es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explicavam o mecanismo onde bastava dizer o que fazer. "`#pragma x` não é reconhecido pelo compilador" → "`#pragma x` não existe" "… — você quis dizer `deprecated`?" → "… — use `deprecated`" "toma o resto da linha como texto — as aspas entrariam na mensagem" → "A mensagem não leva aspas — elas entrariam no texto" "\"x\" está marcado como depreciado" → "\"x\" está depreciado" Nos títulos das ações, o alvo já está no contexto do cursor: "Remover o corpo da declaração" → "Remover o corpo"; "Corrigir a indentação desta linha" → "Corrigir a indentação"; "Renomear \"a\" para \"b\"" → "Renomear para \"b\"". Nos cinco idiomas. --- src/messages/langs/en.rs | 8 ++++---- src/messages/langs/es.rs | 12 ++++-------- src/messages/langs/pt_br.rs | 12 ++++-------- src/messages/langs/ro.rs | 12 ++++-------- src/messages/langs/ru.rs | 10 ++++------ src/server.rs | 12 +++++------- 6 files changed, 25 insertions(+), 41 deletions(-) diff --git a/src/messages/langs/en.rs b/src/messages/langs/en.rs index 5ce65c7..a35b6b1 100644 --- a/src/messages/langs/en.rs +++ b/src/messages/langs/en.rs @@ -15,12 +15,12 @@ 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 {}` is not recognized by the compiler", - MsgKey::PragmaUnknownDidYouMean => "`#pragma {}` is not recognized — did you mean `{sug}`?", + MsgKey::PragmaUnknown => "`#pragma {}` does not exist", + MsgKey::PragmaUnknownDidYouMean => "`#pragma {}` does not exist — use `{sug}`", MsgKey::PragmaDeprecatedQuoted => { - "`#pragma deprecated` takes the rest of the line as text — the quotes would end up in the message" + "The message takes no quotes — they would end up in the text" } MsgKey::SymFromDeprecatedFile => "\"{}\" belongs to a deprecated include", MsgKey::IncludeDeprecated => "\"{}\" is deprecated", diff --git a/src/messages/langs/es.rs b/src/messages/langs/es.rs index bca837d..2ead011 100644 --- a/src/messages/langs/es.rs +++ b/src/messages/langs/es.rs @@ -20,15 +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 es reconocido por el compilador", - MsgKey::PragmaUnknownDidYouMean => { - "`#pragma {}` no es reconocido — ¿querías decir `{sug}`?" - } - MsgKey::PragmaDeprecatedQuoted => { - "`#pragma deprecated` toma el resto de la línea como texto — las comillas quedarían en el mensaje" - } + 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 9c13f99..da5f4e3 100644 --- a/src/messages/langs/pt_br.rs +++ b/src/messages/langs/pt_br.rs @@ -15,15 +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 é reconhecido pelo compilador", - MsgKey::PragmaUnknownDidYouMean => { - "`#pragma {}` não é reconhecido — você quis dizer `{sug}`?" - } - MsgKey::PragmaDeprecatedQuoted => { - "`#pragma deprecated` toma o resto da linha como texto — as aspas entrariam na mensagem" - } + 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 0f7c41a..f29cce7 100644 --- a/src/messages/langs/ro.rs +++ b/src/messages/langs/ro.rs @@ -20,15 +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 este recunoscut de compilator", - MsgKey::PragmaUnknownDidYouMean => { - "`#pragma {}` nu este recunoscut — ai vrut să spui `{sug}`?" - } - MsgKey::PragmaDeprecatedQuoted => { - "`#pragma deprecated` ia restul liniei ca text — ghilimelele ar ajunge în mesaj" - } + 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 f2084fa..ac73f32 100644 --- a/src/messages/langs/ru.rs +++ b/src/messages/langs/ru.rs @@ -20,13 +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 => { - "`#pragma deprecated` берёт остаток строки как текст — кавычки попадут в сообщение" - } + MsgKey::PragmaUnknown => "`#pragma {}` не существует", + MsgKey::PragmaUnknownDidYouMean => "`#pragma {}` не существует — используйте `{sug}`", + MsgKey::PragmaDeprecatedQuoted => "Сообщение пишется без кавычек — они попадут в текст", MsgKey::SymFromDeprecatedFile => "\"{}\" принадлежит устаревшему include", MsgKey::IncludeDeprecated => "\"{}\" устарел", MsgKey::ParamUnused => "Параметр \"{}\" объявлен, но не используется", diff --git a/src/server.rs b/src/server.rs index 89d7be9..2a20c01 100644 --- a/src/server.rs +++ b/src/server.rs @@ -747,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), @@ -783,10 +783,8 @@ fn pragma_actions( }; let Some(fix) = &issue.fix else { continue }; let (title, new_text) = match fix { - PragmaFix::Rename(s) => (format!("Trocar para `#pragma {s}`"), s.clone()), - PragmaFix::Unquote(inner) => { - ("Remover as aspas da mensagem".to_string(), inner.clone()) - } + PragmaFix::Rename(s) => (format!("Usar `#pragma {s}`"), s.clone()), + PragmaFix::Unquote(inner) => ("Remover as aspas".to_string(), inner.clone()), }; let range = Range { start: Position { @@ -963,7 +961,7 @@ fn indent_actions( }; changes.insert(parsed, edits); actions.push(CodeActionOrCommand::CodeAction(CodeAction { - title: "Corrigir a indentação desta linha".to_string(), + title: "Corrigir a indentação".to_string(), kind: Some(CodeActionKind::QUICKFIX), diagnostics: Some(vec![diag.clone()]), edit: Some(WorkspaceEdit { @@ -1050,7 +1048,7 @@ fn removal_title(code: &str) -> 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 da declaração".to_string(), + "PP0002" | "PP0003" => "Remover o corpo".to_string(), _ => "Remover declaração não usada".to_string(), } } From f3c3468b2e894eabd5bee1da82d730954c408b6a Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Tue, 1 Sep 2026 04:53:32 -0300 Subject: [PATCH 11/19] =?UTF-8?q?fix:=20cabe=C3=A7alho=20de=20se=C3=A7?= =?UTF-8?q?=C3=A3o=20com=20texto=20tamb=C3=A9m=20n=C3=A3o=20=C3=A9=20doc?= =?UTF-8?q?=20comment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A correção anterior só reconhecia a régua pura (`// --------`). O cabeçalho que traz texto entre ornamentos continuava virando documentação: // --- PP0004: `stock` sem corpo --------------------- stock FuncaoSemCorpo(playerid); // hover exibia a linha acima Passa a valer também quando a linha abre e fecha com uma corrida de três ou mais ornamentos. O limite de três é o que separa cabeçalho de prosa: um hífen isolado é comum em texto (`// vale -1 quando ausente`) e segue sendo documentação. --- CHANGELOG.md | 15 +++++++----- src/parser/symbols.rs | 56 ++++++++++++++++++++++++++++++++++++------- 2 files changed, 57 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b771f53..82d7e2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -111,12 +111,15 @@ caso encontre por favor relate para ajudar a manter a consistência dos dados. de delimitá-la. Aspas no meio do texto continuam sendo texto legítimo ### Corrigido -- **Réguas de separação viravam documentação** — um cabeçalho de seção - (`// ------------`) acima de uma função era lido como doc comment dela, e o - hover exibia a régua e o texto da seção no lugar da documentação. Uma linha - de comentário composta só de `-`, `=`, `*`, `_`, `#` ou `~` passa a encerrar - a varredura: é ornamento, e o que está acima dela pertence a outra seção. - Comentários `//` com texto continuam sendo documentação, como antes +- **Réguas de separação viravam documentação** — um cabeçalho de seção acima de + uma função era lido como doc comment dela, e o hover exibia a régua e o texto + da seção no lugar da documentação. Passam a encerrar a varredura tanto a + régua pura (`// ---------`) quanto o cabeçalho com texto entre ornamentos + (`// --- Seção 9 ---------`): são separadores, e o que está acima deles + pertence a outra parte do arquivo. O que distingue um do outro é a corrida de + três ou mais ornamentos, que não aparece em prosa — um hífen isolado + (`// vale -1 quando ausente`) continua sendo documentação, como qualquer + comentário `//` com texto - **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**, diff --git a/src/parser/symbols.rs b/src/parser/symbols.rs index 7135aa3..9c06608 100644 --- a/src/parser/symbols.rs +++ b/src/parser/symbols.rs @@ -246,14 +246,31 @@ fn parse_params(raw: &str) -> Vec { params } -/// `true` se o comentário é só uma régua de separação (`// ------`, `// =====`), -/// sem texto — ornamento de seção, não documentação. +/// Caracteres usados como ornamento em cabeçalhos de seção. +const RULE_CHARS: [char; 6] = ['-', '=', '*', '_', '#', '~']; + +/// `true` se o comentário é um separador de seção, e não documentação. +/// +/// Cobre a régua pura (`// ------`) e o cabeçalho que traz texto entre corridas +/// de ornamento (`// --- Seção 9 ---------`). O que separa um do outro é a +/// corrida: três ou mais ornamentos seguidos não aparecem em prosa, ao passo +/// que um hífen isolado é comum (`// vale -1 quando ausente`). fn is_comment_rule(line: &str) -> bool { let body = line.trim_start_matches('/').trim(); - !body.is_empty() - && body - .chars() - .all(|c| matches!(c, '-' | '=' | '*' | '_' | '#' | '~')) + if body.is_empty() { + return false; + } + let is_ornament = |c: char| RULE_CHARS.contains(&c); + + // Só ornamento: régua pura. + if body.chars().all(is_ornament) { + return true; + } + + // Cabeçalho: abre e fecha com uma corrida longa de ornamento. + let opening = body.chars().take_while(|c| is_ornament(*c)).count(); + let closing = body.chars().rev().take_while(|c| is_ornament(*c)).count(); + opening >= 3 && closing >= 3 } fn extract_doc(lines: &[&str], line_idx: usize) -> Option { @@ -1282,14 +1299,37 @@ mod tests { assert_eq!(doc.as_deref(), Some("// Bane alguém.")); } + #[test] + fn cabecalho_de_secao_com_texto_tambem_e_regua() { + // `// --- Seção 9 -----` é separador, não documentação. + assert!(is_comment_rule( + "// --- PP0004: `stock` sem corpo ---------" + )); + assert!(is_comment_rule("// ===== Parte 2 =====")); + // Um hífen isolado é prosa comum, não ornamento. + assert!(!is_comment_rule("// vale -1 quando ausente")); + assert!(!is_comment_rule("// a - b resulta em zero")); + // Corrida curta não caracteriza cabeçalho. + assert!(!is_comment_rule("// -- quase --")); + } + + #[test] + fn cabecalho_com_texto_nao_vira_doc() { + let src = "// --- Seção 4: sem corpo ---------\nstock Fn(playerid);\n"; + let f = parse_file(src); + assert_eq!(f.symbols.iter().find(|s| s.name == "Fn").unwrap().doc, None); + } + #[test] fn regua_reconhece_varios_ornamentos() { assert!(is_comment_rule("// -----")); assert!(is_comment_rule("//=====")); assert!(is_comment_rule("// ***")); assert!(is_comment_rule("// ~~~~~")); - // Com texto, não é régua. - assert!(!is_comment_rule("// --- Seção 9 ---")); + // Texto entre corridas de ornamento é cabeçalho de seção — ver + // `cabecalho_de_secao_com_texto_tambem_e_regua`. + assert!(is_comment_rule("// --- Seção 9 ---")); + // Prosa não é. assert!(!is_comment_rule("// Bane alguém.")); assert!(!is_comment_rule("//")); } From ccf009c34d1d1948a1dea29d9d3746ee3a0cc5cc Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Tue, 1 Sep 2026 04:59:09 -0300 Subject: [PATCH 12/19] =?UTF-8?q?fix:=20doc=20comment=20passa=20a=20ser=20?= =?UTF-8?q?s=C3=B3=20o=20bloco=20imediatamente=20acima?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A varredura subia o arquivo acumulando linhas de comentário, e trazia para o hover réguas, cabeçalhos de seção e o texto de outras funções. As correções anteriores atacavam cada sintoma — reconhecer régua pura, depois cabeçalho com texto entre ornamentos — quando o problema era a varredura não ter fim. Passa a valer a regra do Javadoc/PHPDoc: **um bloco só, o imediatamente acima da declaração**. Um `/* … */` colado nela, ou uma sequência contígua de linhas `//`. Linha em branco ou código entre os dois separa. Com isso saem `is_comment_rule` e a heurística de ornamentos: não é mais preciso adivinhar o que é separador, porque nada além do bloco vizinho é lido. O `#pragma deprecated` entre o comentário e a declaração continua sendo pulado — é a diretiva que marca aquele símbolo. --- CHANGELOG.md | 17 ++- src/parser/symbols.rs | 288 +++++++++++++++++++----------------------- 2 files changed, 141 insertions(+), 164 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82d7e2b..9eb75e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -111,15 +111,14 @@ caso encontre por favor relate para ajudar a manter a consistência dos dados. de delimitá-la. Aspas no meio do texto continuam sendo texto legítimo ### Corrigido -- **Réguas de separação viravam documentação** — um cabeçalho de seção acima de - uma função era lido como doc comment dela, e o hover exibia a régua e o texto - da seção no lugar da documentação. Passam a encerrar a varredura tanto a - régua pura (`// ---------`) quanto o cabeçalho com texto entre ornamentos - (`// --- Seção 9 ---------`): são separadores, e o que está acima deles - pertence a outra parte do arquivo. O que distingue um do outro é a corrida de - três ou mais ornamentos, que não aparece em prosa — um hífen isolado - (`// vale -1 quando ausente`) continua sendo documentação, como qualquer - comentário `//` com texto +- **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**, diff --git a/src/parser/symbols.rs b/src/parser/symbols.rs index 9c06608..4cc92f7 100644 --- a/src/parser/symbols.rs +++ b/src/parser/symbols.rs @@ -246,101 +246,75 @@ fn parse_params(raw: &str) -> Vec { params } -/// Caracteres usados como ornamento em cabeçalhos de seção. -const RULE_CHARS: [char; 6] = ['-', '=', '*', '_', '#', '~']; - -/// `true` se o comentário é um separador de seção, e não documentação. +/// 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. /// -/// Cobre a régua pura (`// ------`) e o cabeçalho que traz texto entre corridas -/// de ornamento (`// --- Seção 9 ---------`). O que separa um do outro é a -/// corrida: três ou mais ornamentos seguidos não aparecem em prosa, ao passo -/// que um hífen isolado é comum (`// vale -1 quando ausente`). -fn is_comment_rule(line: &str) -> bool { - let body = line.trim_start_matches('/').trim(); - if body.is_empty() { - return false; +/// 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 { + // 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 is_ornament = |c: char| RULE_CHARS.contains(&c); - // Só ornamento: régua pura. - if body.chars().all(is_ornament) { - return true; + 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 } - - // Cabeçalho: abre e fecha com uma corrida longa de ornamento. - let opening = body.chars().take_while(|c| is_ornament(*c)).count(); - let closing = body.chars().rev().take_while(|c| is_ornament(*c)).count(); - opening >= 3 && closing >= 3 } -fn extract_doc(lines: &[&str], line_idx: usize) -> Option { - // Guarda as fatias e só materializa a `String` no fim: um bloco de doc tem - // várias linhas, e uma alocação por linha aparece no perfil de um include - // grande. - let mut doc_lines: Vec<&str> = 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; +/// 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")); } - // `#pragma deprecated` fica entre o comentário e a declaração; pular a - // diretiva mantém o doc ligado ao símbolo que ela marca. - if is_pragma_deprecated(l) && !found { - i = idx.checked_sub(1); - continue; - } - if l.starts_with("//") { - // Uma régua (`// -----`) separa seções do arquivo; não documenta o - // símbolo abaixo, e tudo acima dela pertence a outra seção. - if is_comment_rule(l) { - break; - } - doc_lines.push(l); - found = true; - } else if l.ends_with("*/") { - doc_lines.push(l); - // Um bloco de uma linha só (`/** … */`) já está completo; procurar - // o início a partir da linha anterior atravessaria o código acima - // até casar com o `/*` de outro comentário. - if !l.starts_with("/*") { - let mut j = idx.checked_sub(1); - let mut open_found = false; - while let Some(jdx) = j { - let ll = lines[jdx].trim(); - doc_lines.push(ll); - if ll.contains("/*") { - open_found = true; - break; - } - j = jdx.checked_sub(1); - } - // Sem `/*` que abra, o `*/` não pertence a um bloco de doc - // deste símbolo: descarta em vez de arrastar o arquivo inteiro. - if !open_found { - return None; - } - } - 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)] @@ -1238,100 +1212,104 @@ mod tests { ); } + // --- 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 doc_de_bloco_de_uma_linha_nao_atravessa_o_codigo_acima() { - // Um `/** … */` de uma linha já está completo: procurar o `/*` de - // abertura acima faria a varredura engolir funções inteiras até casar - // com o `/**` de outro comentário. - let src = "/**\n * Doc da funcao.\n */\nstock Antiga() { return 1; }\n\n/** Limite antigo. */\n#define MAX_X (50)\n"; - let f = parse_file(src); - let d = f.symbols.iter().find(|s| s.name == "MAX_X").unwrap(); - assert_eq!(d.doc.as_deref(), Some("/** Limite antigo. */")); + 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 fim_de_bloco_sem_abertura_nao_vira_doc() { - // `*/` solto acima da declaração: sem `/*` que abra, não é doc deste - // símbolo — arrastar o arquivo até o topo seria pior que não ter doc. - let src = "stock Outra() { return 1; }\nalgo */\n#define MAX_Y (1)\n"; - let f = parse_file(src); - let d = f.symbols.iter().find(|s| s.name == "MAX_Y").unwrap(); - assert_eq!(d.doc, None); + 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 regua_de_separacao_nao_e_doc() { - // Uma régua separa seções do arquivo; nem ela nem o que vem acima - // documentam o símbolo abaixo. - let src = "// -----------------\n// 9. Outra seção\n// -----------------\n\nstock Fn() { return 1; }\n"; - let f = parse_file(src); - assert_eq!(f.symbols.iter().find(|s| s.name == "Fn").unwrap().doc, None); + 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 comentario_de_linha_continua_sendo_doc() { - // `//` acima da declaração é convenção legítima em Pawn — só a régua sai. - let src = "// Devolve o nome do jogador.\nstock Nome() { return 1; }\n"; - let f = parse_file(src); - let doc = f - .symbols - .iter() - .find(|s| s.name == "Nome") - .unwrap() - .doc - .clone(); - assert_eq!(doc.as_deref(), Some("// Devolve o nome do jogador.")); + 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 doc_apos_uma_regua_e_preservado() { - // A régua encerra a varredura, mas o que está entre ela e a declaração - // é documentação de verdade. - let src = "// =========\n// Bane alguém.\nstock Ban() { return 1; }\n"; - let f = parse_file(src); - let doc = f - .symbols - .iter() - .find(|s| s.name == "Ban") - .unwrap() - .doc - .clone(); - assert_eq!(doc.as_deref(), Some("// Bane alguém.")); + 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 cabecalho_de_secao_com_texto_tambem_e_regua() { - // `// --- Seção 9 -----` é separador, não documentação. - assert!(is_comment_rule( - "// --- PP0004: `stock` sem corpo ---------" - )); - assert!(is_comment_rule("// ===== Parte 2 =====")); - // Um hífen isolado é prosa comum, não ornamento. - assert!(!is_comment_rule("// vale -1 quando ausente")); - assert!(!is_comment_rule("// a - b resulta em zero")); - // Corrida curta não caracteriza cabeçalho. - assert!(!is_comment_rule("// -- quase --")); + 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_com_texto_nao_vira_doc() { - let src = "// --- Seção 4: sem corpo ---------\nstock Fn(playerid);\n"; + 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); - assert_eq!(f.symbols.iter().find(|s| s.name == "Fn").unwrap().doc, None); + 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 regua_reconhece_varios_ornamentos() { - assert!(is_comment_rule("// -----")); - assert!(is_comment_rule("//=====")); - assert!(is_comment_rule("// ***")); - assert!(is_comment_rule("// ~~~~~")); - // Texto entre corridas de ornamento é cabeçalho de seção — ver - // `cabecalho_de_secao_com_texto_tambem_e_regua`. - assert!(is_comment_rule("// --- Seção 9 ---")); - // Prosa não é. - assert!(!is_comment_rule("// Bane alguém.")); - assert!(!is_comment_rule("//")); + fn declaracao_na_primeira_linha_nao_tem_doc() { + assert_eq!(doc_de("stock Fn() { return 1; }\n", "Fn"), None); } #[test] From 556f7fe8074fd16621db7ccbeccd3be844ee2212 Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Tue, 1 Sep 2026 05:20:58 -0300 Subject: [PATCH 13/19] docs: badge de estrelas no README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 0cc369a..0268840 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) From 77a45e788ceb414f35f29c15d172d08ad1723761 Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Tue, 1 Sep 2026 08:49:45 -0300 Subject: [PATCH 14/19] =?UTF-8?q?feat:=20estilo=20de=20nomenclatura=20acei?= =?UTF-8?q?ta=20regex=20do=20usu=C3=A1rio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As categorias aceitavam só os cinco estilos embutidos. Quem tem convenção própria (prefixo de global, notação húngara) não tinha como descrevê-la. Um item da lista no formato /padrão/ passa a ser lido como regex. Convive com os embutidos pela regra que já existia: o nome passa se casar com QUALQUER critério da categoria. O regex é âncorado como ^(?:...)$ — o usuário descreve o nome inteiro, e o agrupamento evita que uma alternância no topo ancore só os extremos. Um padrão inválido é ignorado em vez de derrubar a análise, e o '_' inicial não é removido: quem escreve o padrão decide se o aceita. O critério vira o tipo Rule em vez de uma variante de Case, que é Copy e alimenta o gerador de renomeação: de um regex dá para saber se o nome passa, não como reescrevê-lo. Por isso Rule::builtin devolve None para regex e o quick fix não oferece sugestão inválida. WrongStyle passa a carregar String: o rótulo de um regex só existe em runtime. --- src/naming/mod.rs | 13 ++-- src/naming/rules.rs | 15 +++-- src/naming/style.rs | 158 ++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 171 insertions(+), 15 deletions(-) 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..f4a21d6 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() } @@ -184,7 +184,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 +208,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..5b0cbea 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,85 @@ fn is_cap_snake(s: &str) -> bool { mod tests { use super::*; + #[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 +316,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] From 15c42743c8f32f13ebc7374d6bb1aaa0b50b28c7 Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Tue, 1 Sep 2026 08:53:03 -0300 Subject: [PATCH 15/19] =?UTF-8?q?test:=20padr=C3=A3o=20do=20usu=C3=A1rio?= =?UTF-8?q?=20na=20avalia=C3=A7=C3=A3o=20de=20nomes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cobre o caminho completo, do item de configuração ao diagnóstico: o padrão aceitando a convenção própria, sinalizando o que não casa, convivendo com um estilo embutido na mesma categoria, e um padrão inválido sendo ignorado sem levar junto os critérios válidos ao lado. --- src/naming/rules.rs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/naming/rules.rs b/src/naming/rules.rs index f4a21d6..d529fc4 100644 --- a/src/naming/rules.rs +++ b/src/naming/rules.rs @@ -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(); From 5b1c965d6ba34a495fa8827729ad6ac31774e18f Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Tue, 1 Sep 2026 09:11:51 -0300 Subject: [PATCH 16/19] =?UTF-8?q?test:=20padr=C3=A3o=20catastr=C3=B3fico?= =?UTF-8?q?=20n=C3=A3o=20degrada=20a=20an=C3=A1lise?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A crate regex garante tempo linear — não há backtracking, então (a+)+ com entrada que não casa termina de imediato. O teste fixa essa propriedade, que é o motivo de um padrão do usuário poder ser aceito sem limite de forma. --- src/naming/style.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/naming/style.rs b/src/naming/style.rs index 5b0cbea..d62e7fe 100644 --- a/src/naming/style.rs +++ b/src/naming/style.rs @@ -180,6 +180,17 @@ 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!( From 15918a9a1661d67b4a386a8ed92c5667b040f158 Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:59:06 -0300 Subject: [PATCH 17/19] =?UTF-8?q?docs:=20padr=C3=A3o=20pr=C3=B3prio=20no?= =?UTF-8?q?=20changelog=20e=20no=20guia=20do=20assistente=20de=20nomes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O changelog da 1.4.0 não cobria o padrão por expressão regular, que entrou depois de ele ter sido fechado. No guia do assistente, o exemplo de configuração mostrava os estilos como texto e citava uma categoria 'enums' que não existe — são listas, e as categorias são constants e macros. A seção de estilo passa a descrever o tipo Rule: por que o regex não é uma variante de Case (Case é Copy e alimenta o suggest, que gera nomes), a âncora implícita, o tratamento do '_' inicial e do padrão inválido, e a mudança de WrongStyle para Vec. Contagem de diagnósticos corrigida: o CLAUDE.md dizia PP0013 e o CONTRIBUTING dizia PP0017; são 19 desde o PP0019. --- CHANGELOG.md | 17 +++++++++++++++++ CLAUDE.md | 2 +- CONTRIBUTING.md | 2 +- docs/naming-assistant.md | 36 +++++++++++++++++++++++++++--------- 4 files changed, 46 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9eb75e4..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 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/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. From bca3d879251802bf13285f2d96055b3ec165c564 Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:01:55 -0300 Subject: [PATCH 18/19] docs: README diz o que o motor entrega e corrige o que estava errado MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Afirmava que a extensão 'recua para o modo TypeScript como fallback' se o binário faltar. Esse fallback não existe no código da extensão. - Dizia 17 códigos de diagnóstico; são 19, e 11 têm correção automática. - Faltavam nas capacidades o assistente de nomenclatura e as code actions. A abertura passa a dizer por que o motor é um processo separado — analisar o gamemode com todos os includes sem travar a digitação — e que a análise é real, não realce por expressão regular. --- README.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0268840..1ca1506 100644 --- a/README.md +++ b/README.md @@ -16,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; 11 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 `,`. @@ -34,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 11 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). From 4641e78271259a3570aebae82fb23238f4e4fb98 Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:07:44 -0300 Subject: [PATCH 19/19] =?UTF-8?q?docs:=2013=20diagn=C3=B3sticos=20com=20co?= =?UTF-8?q?rre=C3=A7=C3=A3o=20autom=C3=A1tica,=20n=C3=A3o=2011?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contei só os códigos citados em server.rs; os de remoção de código não usado (PP0006 e PP0016) ficam em intellisense/quickfix.rs e tinham ficado de fora. O docs/diagnostics.md já listava os 13 corretamente. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1ca1506..6fffafc 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ A extensão inicia o motor automaticamente quando encontra o binário; ele acomp ## Capacidades -- **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; 11 deles com correção automática (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 `,`. @@ -37,7 +37,7 @@ A extensão inicia o motor automaticamente quando encontra o binário; ele acomp - **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 11 dos diagnósticos. +- **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).