diff --git a/Cargo.lock b/Cargo.lock index 23f8ecb61..00616fb8e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4310,6 +4310,7 @@ dependencies = [ "ide-core", "parking_lot", "serde", + "serde_json", "specta", "spellbook", "tracing", diff --git a/crates/ide-core/src/inspection_settings.rs b/crates/ide-core/src/inspection_settings.rs index 7b28189b8..f4522a2e1 100644 --- a/crates/ide-core/src/inspection_settings.rs +++ b/crates/ide-core/src/inspection_settings.rs @@ -64,6 +64,10 @@ pub struct InspectionOverride { pub enabled: Option, #[specta(optional)] pub severity: Option, + /// The inspection's own options (Typo: `processCode`, `processLiterals`, + /// `processComments`); absent ones keep their defaults. + #[specta(optional)] + pub options: Option>, } /// IDEA's highlighting levels. @@ -101,5 +105,10 @@ mod tests { assert_eq!(s.ide_profiles["strict"].name, "Strict"); assert_eq!(s.ide_profiles["strict"].overrides["lsp:ruff"].severity, Some(InspectionLevel::Error)); assert!(serde_json::from_str::(r#"{"default":{"x":{"severity":"loud"}}}"#).is_err()); + let s: InspectionSettings = + serde_json::from_str(r#"{"default":{"builtin:spelling":{"options":{"processCode":false}}}}"#).unwrap(); + assert!(!s.default["builtin:spelling"].options.as_ref().unwrap()["processCode"]); + let back: InspectionSettings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap(); + assert_eq!(back, s); } } diff --git a/crates/ide-spell/Cargo.toml b/crates/ide-spell/Cargo.toml index 119493098..5fe6a9d01 100644 --- a/crates/ide-spell/Cargo.toml +++ b/crates/ide-spell/Cargo.toml @@ -13,3 +13,6 @@ tracing.workspace = true spellbook = "0.4" serde.workspace = true specta.workspace = true + +[dev-dependencies] +serde_json.workspace = true diff --git a/crates/ide-spell/src/dict/software.txt b/crates/ide-spell/src/dict/software.txt index 0a92977c9..b67a860cd 100644 --- a/crates/ide-spell/src/dict/software.txt +++ b/crates/ide-spell/src/dict/software.txt @@ -461,3 +461,45 @@ workspaces xml yaml yml +# Python (IDEA's Python plugin bundles a similar dictionary) +asyncio +awaitable +classmethod +contextmanager +dataclass +dataclasses +dtype +dtypes +dunder +elif +kwarg +ndarray +nonlocal +numpy +pydantic +pyproject +pytest +setuptools +staticmethod +venv +virtualenv +# Services and infrastructure +checkpointing +dockerfile +embeddings +healthcheck +hotfix +kubectl +kubernetes +llm +llms +mysql +postgres +postgresql +rollout +subnet +upsert +webhook +webhooks +websocket +websockets diff --git a/crates/ide-spell/src/lib.rs b/crates/ide-spell/src/lib.rs index f8cb34e08..0ac12038b 100644 --- a/crates/ide-spell/src/lib.rs +++ b/crates/ide-spell/src/lib.rs @@ -2,6 +2,8 @@ //! comments, string literals and declared names (regions come from the //! syntax layer), checked against the bundled en_US Hunspell dictionary, a //! list of programming terms and the user's accepted words and dictionaries. +//! Regular agent nouns of known words (`checkpointer`) pass everywhere; in +//! identifiers so do closed compounds of known words (`codebase`). mod words; @@ -35,12 +37,62 @@ pub struct Region { pub kind: RegionKind, } +/// What a typo is part of: correcting one in `Code` (a declared name) must +/// rename the symbol; the others may replace the word in place. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum TypoContext { + Code, + Literal, + Comment, + Prose, +} + +impl From for TypoContext { + fn from(kind: RegionKind) -> Self { + match kind { + RegionKind::Name => Self::Code, + RegionKind::String => Self::Literal, + RegionKind::Comment => Self::Comment, + RegionKind::Prose => Self::Prose, + } + } +} + /// A misspelt word. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] pub struct Typo { /// UTF-16 range of the word. pub range: Range, pub word: String, + pub context: TypoContext, +} + +/// The Typo inspection's options (IDEA's "Process code / literals / comments"); +/// plain text and Markdown are always checked. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct SpellScope { + pub code: bool, + pub literals: bool, + pub comments: bool, +} + +impl Default for SpellScope { + fn default() -> Self { + Self { code: true, literals: true, comments: true } + } +} + +impl SpellScope { + pub fn includes(&self, kind: RegionKind) -> bool { + match kind { + RegionKind::Name => self.code, + RegionKind::String => self.literals, + RegionKind::Comment => self.comments, + RegionKind::Prose => true, + } + } } /// At most this many typos are reported per text. @@ -55,6 +107,50 @@ fn english() -> &'static spellbook::Dictionary { }) } +/// Lowercase entries of the dictionary as listed (no affixes applied, no proper nouns). +fn lemmas() -> &'static HashSet<&'static str> { + static LEMMAS: OnceLock> = OnceLock::new(); + LEMMAS.get_or_init(|| { + include_str!("dict/en_US.dic") + .lines() + .skip(1) + .map(|l| l.split('/').next().unwrap_or(l).trim()) + .filter(|w| !w.is_empty() && w.chars().all(|c| c.is_ascii_lowercase())) + .collect() + }) +} + +/// Common misspellings made of two words; never accepted as compounds. +const NOT_COMPOUNDS: &[&str] = &["eachother", "everytime", "publically", "aswell", "infact", "atleast", "alot"]; + +/// Shortest dictionary word in a derivation or compound (programming terms: 3). +const MIN_PART: usize = 4; + +/// Whether English doubles the final consonant before `-er` (`program` → +/// `programmer`): the stem ends consonant, single vowel, consonant (not w/x/y). +fn doubles_final_consonant(stem: &str) -> bool { + let vowel = |c: u8| b"aeiou".contains(&c); + match stem.as_bytes() { + [.., a, b, c] => !vowel(*a) && vowel(*b) && !vowel(*c) && !b"wxy".contains(c), + _ => false, + } +} + +/// Whether dropping one letter or swapping two adjacent ones gives a dictionary +/// word: then a compound (`writ`+`ting`) is more likely a typo of +/// it (`writing`), as Hunspell's CHECKCOMPOUNDREP reasons. +fn near_word(lower: &str) -> bool { + let b = lower.as_bytes(); + let dict = english(); + let check = |v: &[u8]| std::str::from_utf8(v).is_ok_and(|w| dict.check(w)); + (0..b.len()).any(|i| check(&[&b[..i], &b[i + 1..]].concat())) + || (0..b.len().saturating_sub(1)).any(|i| { + let mut v = b.to_vec(); + v.swap(i, i + 1); + v != b && check(&v) + }) +} + fn software_terms() -> &'static HashSet<&'static str> { static TERMS: OnceLock> = OnceLock::new(); TERMS.get_or_init(|| { @@ -118,23 +214,71 @@ impl Speller { } } - /// Whether `word` is spelt correctly. Case is forgiven the way identifiers - /// need it: `url` passes for `URL`, `paris` for `Paris`. + /// Whether `word` (of prose, a comment or a string) is spelt correctly. + /// Case is forgiven the way identifiers need it: `url` passes for `URL`, + /// `paris` for `Paris`. Regular agent nouns of known words pass too + /// (`checkpointer`, `serializers`). pub fn is_correct(&self, word: &str) -> bool { let lower = word.to_lowercase(); - if self.accepted.contains(&lower) - || software_terms().contains(lower.as_str()) - || self.custom.iter().any(|d| d.contains(&lower)) - { + self.listed(word, &lower) || self.agent_noun(&lower) + } + + /// [`Self::is_correct`] for a part of an identifier, which may also be two + /// known words run together (`codebase`, `urlpath`), as identifiers often are. + pub fn is_correct_identifier(&self, word: &str) -> bool { + self.is_correct(word) || self.compound(&word.to_lowercase()) + } + + fn user_word(&self, lower: &str) -> bool { + self.accepted.contains(lower) + || software_terms().contains(lower) + || self.custom.iter().any(|d| d.contains(lower)) + } + + /// In a dictionary, in any case style. + fn listed(&self, word: &str, lower: &str) -> bool { + if self.user_word(lower) { return true; } let dict = english(); - if dict.check(word) || dict.check(&lower) { - return true; + dict.check(word) || dict.check(lower) || dict.check(&capitalize(lower)) || dict.check(&lower.to_uppercase()) + } + + /// `er(s)` / `r(s)` of a listed stem: `checkpointer` + /// (checkpoint), `serializers` (serialize), `queriers` (query). Not when + /// English doubles the final consonant (`programer`) or the `-or` noun is + /// the word (`processer`, `generater`). + fn agent_noun(&self, lower: &str) -> bool { + if !lower.bytes().all(|c| c.is_ascii_lowercase()) { + return false; } - let mut chars = lower.chars(); - let capitalized: String = chars.next().into_iter().flat_map(char::to_uppercase).chain(chars).collect(); - dict.check(&capitalized) || dict.check(&lower.to_uppercase()) + let singular = lower.strip_suffix('s').unwrap_or(lower); + let Some(before_r) = singular.strip_suffix('r') else { return false }; + let Some(before_er) = before_r.strip_suffix('e') else { return false }; + // (stem, root the `-or` noun would be built on) + let mut stems = vec![(before_r.to_string(), before_er)]; + if let Some(y) = before_er.strip_suffix('i') { + stems.push((format!("{y}y"), before_er)); + } else if !before_er.ends_with('e') && !doubles_final_consonant(before_er) { + stems.push((before_er.to_string(), before_er)); + } + stems.into_iter().any(|(stem, root)| { + stem.len() >= MIN_PART + && (lemmas().contains(stem.as_str()) || self.user_word(&stem)) + && !english().check(&format!("{root}or")) + }) + } + + /// Two known words run together, each at least [`MIN_PART`] letters (or a + /// 3-letter programming term: `urlpath`). Proper nouns don't count. + fn compound(&self, lower: &str) -> bool { + if lower.len() < 6 || !lower.bytes().all(|c| c.is_ascii_lowercase()) || NOT_COMPOUNDS.contains(&lower) { + return false; + } + let part = |p: &str| { + (p.len() >= 3 && self.user_word(p)) || (p.len() >= MIN_PART && (english().check(p) || self.agent_noun(p))) + }; + (3..=lower.len() - 3).any(|i| part(&lower[..i]) && part(&lower[i..])) && !near_word(lower) } /// Replacements for `word`, best first, in the word's case style. @@ -155,19 +299,22 @@ impl Speller { /// `cancelled` fires. pub fn check(&self, text: &str, regions: &[Region], cancelled: &dyn Fn() -> bool) -> Option> { let mut spans = Vec::new(); - let mut verdicts: HashMap<&str, bool> = HashMap::new(); - let mut bad: Vec = Vec::new(); + let mut verdicts: HashMap<(&str, bool), bool> = HashMap::new(); + let mut bad: Vec<(WordSpan, RegionKind)> = Vec::new(); for (i, r) in regions.iter().enumerate() { if i % 64 == 0 && cancelled() { return None; } spans.clear(); words(text, r.start, r.end, r.kind, &mut spans); + let name = r.kind == RegionKind::Name; for w in &spans { let word = &text[w.start..w.end]; - let ok = *verdicts.entry(word).or_insert_with(|| self.is_correct(word)); + let ok = *verdicts + .entry((word, name)) + .or_insert_with(|| if name { self.is_correct_identifier(word) } else { self.is_correct(word) }); if !ok { - bad.push(*w); + bad.push((*w, r.kind)); } } if bad.len() >= MAX_TYPOS { @@ -175,14 +322,15 @@ impl Speller { break; } } - bad.sort_by_key(|w| w.start); - bad.dedup(); + bad.sort_by_key(|(w, _)| w.start); + bad.dedup_by_key(|(w, _)| *w); let mut index = Utf16Walker::new(text); Some( bad.into_iter() - .map(|w| Typo { + .map(|(w, kind)| Typo { range: Range::new(index.pos(w.start), index.pos(w.end)), word: text[w.start..w.end].to_string(), + context: kind.into(), }) .collect(), ) @@ -291,6 +439,118 @@ mod tests { assert!(!s.is_correct("lenght")); } + #[test] + fn agent_nouns_of_known_words() { + let s = Speller::default(); + for ok in ["checkpointer", "Checkpointers", "serializer", "queriers", "tokenizer", "serializers", "pointer"] { + assert!(s.is_correct(ok), "{ok}"); + } + // Real misspellings of the same shape stay flagged. + for bad in ["programer", "processer", "generater", "writter", "mesager", "begginer"] { + assert!(!s.is_correct(bad), "{bad}"); + } + } + + #[test] + fn compounds_only_in_identifiers() { + let s = Speller::default(); + for ok in ["filepath", "dataframe", "username", "codebase", "pathname", "urlpath", "timestampfile"] { + assert!(s.is_correct_identifier(ok), "{ok}"); + } + assert!(!s.is_correct("dataframe")); + for bad in ["eachother", "everytime", "publically", "mesage", "lenght", "recieve", "comming", "withold"] { + assert!(!s.is_correct_identifier(bad), "{bad}"); + } + } + + /// Common English misspellings must survive the derivation and compound rules. + #[test] + fn common_misspellings_stay_flagged() { + let s = Speller::default(); + let typos = [ + "accomodate", + "acheive", + "accross", + "adress", + "arguement", + "becuase", + "beleive", + "begining", + "calender", + "comming", + "commited", + "concious", + "definately", + "dependancy", + "enviroment", + "existance", + "explaination", + "fucntion", + "funtion", + "goverment", + "heigth", + "independant", + "intial", + "lenght", + "maintainance", + "mesage", + "neccessary", + "occured", + "occurence", + "paramter", + "persistant", + "posession", + "programer", + "recieve", + "refrence", + "relevent", + "retreive", + "seperate", + "seperator", + "succesful", + "tommorow", + "truely", + "untill", + "wich", + "widht", + "wierd", + "writting", + ]; + let accepted: Vec<_> = typos + .iter() + .filter(|t| s.is_correct(t) || s.is_correct_identifier(t)) + .filter(|t| **t != "calender") + .collect(); + assert!(accepted.is_empty(), "accepted: {accepted:?}"); + } + + #[test] + fn issue_42_python_example() { + let s = Speller::default(); + let text = "# Initialize the checkpointer connection pool.\ncheckpointer_pool = None\nmemory_checkpointer = None\nmesage = 'hello'\n"; + let regions = vec![ + Region { start: 0, end: 46, kind: RegionKind::Comment }, + Region { start: 47, end: 64, kind: RegionKind::Name }, + Region { start: 72, end: 91, kind: RegionKind::Name }, + Region { start: 99, end: 105, kind: RegionKind::Name }, + ]; + assert_eq!(&text[99..105], "mesage"); + let typos = s.check(text, ®ions, &|| false).unwrap(); + let found: Vec<_> = typos.iter().map(|t| (t.word.as_str(), t.context)).collect(); + assert_eq!(found, [("mesage", TypoContext::Code)]); + } + + #[test] + fn scope_filters_region_kinds() { + let comments_only = SpellScope { code: false, literals: false, comments: true }; + assert!(comments_only.includes(RegionKind::Comment)); + assert!(comments_only.includes(RegionKind::Prose)); + assert!(!comments_only.includes(RegionKind::Name)); + assert!(!comments_only.includes(RegionKind::String)); + let parsed: SpellScope = serde_json::from_str(r#"{"literals":false}"#).unwrap(); + assert_eq!(parsed, SpellScope { code: true, literals: false, comments: true }); + } + #[test] fn accepted_words_and_custom_dictionaries() { let dir = std::env::temp_dir().join(format!("ide-spell-{}", std::process::id())); diff --git a/src-tauri/src/commands/spell.rs b/src-tauri/src/commands/spell.rs index 50ccba33c..715f94faf 100644 --- a/src-tauri/src/commands/spell.rs +++ b/src-tauri/src/commands/spell.rs @@ -5,7 +5,7 @@ use crate::state::{AppState, Project}; use ide_core::settings::SpellingSettings; use ide_core::{DocId, IdeError, IdeResult, RequestId}; use ide_language::SpellingKind; -use ide_spell::{Region, RegionKind, Speller, Typo}; +use ide_spell::{Region, RegionKind, SpellScope, Speller, Typo}; use std::path::{Path, PathBuf}; use tauri::{State, Window}; @@ -33,13 +33,15 @@ fn speller(project: &Project) -> Speller { } /// Misspelt words in the comments, string literals and declared names of -/// `doc` (all of its text for prose). Cancel with `cancel_request(request)`. +/// `doc` that `scope` includes (all of its text for prose). Cancel with +/// `cancel_request(request)`. #[tauri::command] #[specta::specta] pub async fn spell_check( window: Window, state: State<'_, AppState>, doc: DocId, + scope: SpellScope, request: RequestId, ) -> IdeResult> { let project = state.project(window.label())?; @@ -65,6 +67,7 @@ pub async fn spell_check( SpellingKind::Name => RegionKind::Name, }, }) + .filter(|r| scope.includes(r.kind)) .collect() }; if cancel.is_cancelled() { diff --git a/src/bindings.ts b/src/bindings.ts index 76160eb5d..e5b502124 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -222,9 +222,10 @@ export const commands = { webUnresolvedReferences: (doc: DocId) => typedError(__TAURI_INVOKE("web_unresolved_references", { doc })), /** * Misspelt words in the comments, string literals and declared names of - * `doc` (all of its text for prose). Cancel with `cancel_request(request)`. + * `doc` that `scope` includes (all of its text for prose). Cancel with + * `cancel_request(request)`. */ - spellCheck: (doc: DocId, request: RequestId) => typedError(__TAURI_INVOKE("spell_check", { doc, request })), + spellCheck: (doc: DocId, scope: SpellScope, request: RequestId) => typedError(__TAURI_INVOKE("spell_check", { doc, scope, request })), /** Replacements for a misspelt `word`, best first (Typo › Change to…). */ spellSuggest: (word: string) => typedError(__TAURI_INVOKE("spell_suggest", { word })), /** @@ -2748,6 +2749,11 @@ export type InspectionLevel = "error" | "warning" | "weakWarning" | "serverProbl export type InspectionOverride = { enabled?: boolean | null, severity?: InspectionLevel | null, + /** + * The inspection's own options (Typo: `processCode`, `processLiterals`, + * `processComments`); absent ones keep their defaults. + */ + options?: { [key in string]: boolean } | null, }; export type InspectionSettings = { @@ -4333,6 +4339,16 @@ export type SourceFolder = { detectedFrom: string | null, }; +/** + * The Typo inspection's options (IDEA's "Process code / literals / comments"); + * plain text and Markdown are always checked. + */ +export type SpellScope = { + code?: boolean, + literals?: boolean, + comments?: boolean, +}; + /** Editor › Natural Languages › Spelling. */ export type SpellingSettings = { /** Application-level dictionary: accepted words (IDE settings). */ @@ -4732,8 +4748,15 @@ export type Typo = { /** UTF-16 range of the word. */ range: Range, word: string, + context: TypoContext, }; +/** + * What a typo is part of: correcting one in `Code` (a declared name) must + * rename the symbol; the others may replace the word in place. + */ +export type TypoContext = "code" | "literal" | "comment" | "prose"; + /** What [`FileMoves::undo`] did. */ export type UndoOutcome = { /** Moves reverted (each `from` is the original location again). */ diff --git a/src/editor/lsp/Popups.module.css b/src/editor/lsp/Popups.module.css index ca8cf7c3a..9931c3de6 100644 --- a/src/editor/lsp/Popups.module.css +++ b/src/editor/lsp/Popups.module.css @@ -129,6 +129,28 @@ .spacer { flex: 1; } +.subArrow { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + margin-right: -4px; + padding: 0; + border: none; + border-radius: var(--radius-small); + background: none; + color: var(--fg-muted); +} +.subArrow:hover { + background-color: var(--bg-toolbar-hover); +} +.subMenu { + padding: 6px 0; + min-width: 200px; + max-width: 420px; +} .detail { flex: 0 1 auto; min-width: 0; diff --git a/src/editor/lsp/Popups.test.tsx b/src/editor/lsp/Popups.test.tsx index 98c3aebfb..a5217ead5 100644 --- a/src/editor/lsp/Popups.test.tsx +++ b/src/editor/lsp/Popups.test.tsx @@ -48,6 +48,39 @@ describe("list popup", () => { expect(useLspPopup.getState().popup).toBeNull(); }); + it("opens a row's → menu with the right arrow and runs its items", () => { + const disable = vi.fn(); + const fix = vi.fn(); + openPopup({ + kind: "list", + anchor: { x: 0, y: 0 }, + title: "Intentions", + items: [ + { + id: "fix", + label: "Typo: Change to…", + run: fix, + submenu: [ + { label: "Edit inspection profile setting", run: vi.fn() }, + { label: "Disable inspection", run: disable }, + ], + }, + ], + }); + render(); + expect(screen.getByRole("button", { name: "Typo: Change to… options" })).toBeTruthy(); + fireEvent.keyDown(screen.getByRole("listbox"), { key: "ArrowRight" }); + const menu = screen.getByRole("menu", { name: "Typo: Change to…" }); + expect([...menu.querySelectorAll('[role="menuitem"]')].map((b) => b.textContent)).toEqual([ + "Edit inspection profile setting", + "Disable inspection", + ]); + fireEvent.click(screen.getByRole("menuitem", { name: "Disable inspection" })); + expect(disable).toHaveBeenCalledOnce(); + expect(fix).not.toHaveBeenCalled(); + expect(useLspPopup.getState().popup).toBeNull(); + }); + it("reports the selected row and cleans up on close", () => { const onActive = [vi.fn(), vi.fn()]; const onClose = vi.fn(); diff --git a/src/editor/lsp/Popups.tsx b/src/editor/lsp/Popups.tsx index 3db73a6ce..4873c5463 100644 --- a/src/editor/lsp/Popups.tsx +++ b/src/editor/lsp/Popups.tsx @@ -9,6 +9,7 @@ import { IconButton, Input, List, + MenuList, Popup, Select, SymbolBadge, @@ -117,9 +118,25 @@ function ListPopup({ popup }: { popup: Extract }) { void item.run(); }; + // IDEA's right-arrow menu of a row (inspection options of a fix). + const bodyRef = useRef(null); + const [sub, setSub] = useState<{ item: ListPopupItem; anchor: Element } | null>(null); + const openSub = (item: ListPopupItem, row?: Element | null) => { + if (!item.submenu?.length || item.disabled || !bodyRef.current) return; + const anchor = row ?? bodyRef.current.querySelector('[role="option"][aria-selected="true"]') ?? bodyRef.current; + setSub({ item, anchor }); + }; + const closeSub = () => { + setSub(null); + bodyRef.current?.querySelector('[role="listbox"]')?.focus(); + }; + const onKeyDown = (e: KeyboardEvent) => { if (e.metaKey || e.ctrlKey || e.altKey) return; - if (popup.numbered && !query && /^[1-9]$/.test(e.key)) { + if (e.key === "ArrowRight" && activeItem?.submenu?.length) { + e.preventDefault(); + openSub(activeItem); + } else if (popup.numbered && !query && /^[1-9]$/.test(e.key)) { const item = popup.items[Number(e.key) - 1]; e.preventDefault(); if (item) activate(item); @@ -179,7 +196,7 @@ function ListPopup({ popup }: { popup: Extract }) { /> )} -
+
r.item.id} @@ -207,6 +224,21 @@ function ListPopup({ popup }: { popup: Extract }) { {item.detail && {item.detail}} {item.shortcut && {item.shortcut}} + {item.submenu?.length ? ( + + ) : null}
); }} @@ -214,6 +246,20 @@ function ListPopup({ popup }: { popup: Extract }) {
{popup.preview && } + {sub?.item.submenu && ( + + + + )} ); } diff --git a/src/editor/lsp/diagnostics.ts b/src/editor/lsp/diagnostics.ts index c1857ff5d..a056182d4 100644 --- a/src/editor/lsp/diagnostics.ts +++ b/src/editor/lsp/diagnostics.ts @@ -10,6 +10,7 @@ import { EditorView, ViewPlugin } from "@codemirror/view"; import { forEachDiagnostic, linter, setDiagnostics, type Diagnostic as LintDiagnostic } from "@codemirror/lint"; import type { Diagnostic, Severity } from "@/bindings"; import { onIdeEvent } from "@/platform/ipc"; +import { publishLocalDiagnostics } from "@/platform/localDiagnostics"; import { scrollbarMarks, type ScrollbarMark } from "@/editor/api"; import { rangeToOffsets } from "./positions"; import { lspDoc } from "./lsp"; @@ -17,10 +18,12 @@ import { showHint } from "./hint"; import { editorPrefs } from "@/platform/editorSettings"; import { applyProfile, + diagnosticInspection, inspectionProfile, isTypoStyled, noteDiagnosticCodes, subscribeInspectionProfile, + type InspectionRef, } from "@/platform/inspections"; type Listener = () => void; @@ -28,7 +31,15 @@ type Listener = () => void; const byPath = new Map>(); const listeners = new Map>(); +/** Diagnostics a UI-side inspection computed for `path` (shown in the editor and Problems). */ export function setFileDiagnostics(path: string, source: string, items: Diagnostic[]) { + const had = byPath.get(path)?.has(source) ?? false; + storeDiagnostics(path, source, items); + // `server` names the producer for views that see the diagnostic alone (inspection controls). + if (items.length || had) publishLocalDiagnostics(path, source, items.map((d) => (d.server ? d : { ...d, server: source }))); +} + +function storeDiagnostics(path: string, source: string, items: Diagnostic[]) { let sources = byPath.get(path); if (!items.length) { if (!sources?.delete(source)) return; @@ -49,6 +60,23 @@ export function diagnosticsFor(path: string): Diagnostic[] { return [...sources].flatMap(([source, items]) => applyProfile(profile, source, items)); } +/** Inspections reporting a problem at `pos` of `path` (profile applied), most severe first. */ +export function inspectionsAt(path: string, doc: Text, pos: number): InspectionRef[] { + const sources = byPath.get(path); + if (!sources) return []; + const profile = inspectionProfile(); + const found: { ref: InspectionRef; rank: number }[] = []; + for (const [producer, items] of sources) { + for (const d of applyProfile(profile, producer, items)) { + const { from, to } = rangeToOffsets(doc, d.range); + if (pos < from || pos > to) continue; + const ref = diagnosticInspection(producer, d); + if (ref && !found.some((f) => f.ref.id === ref.id)) found.push({ ref, rank: SEVERITY_RANK[d.severity] }); + } + } + return found.sort((a, b) => a.rank - b.rank).map((f) => f.ref); +} + export function subscribeDiagnostics(path: string, l: Listener): () => void { let set = listeners.get(path); if (!set) listeners.set(path, (set = new Set())); @@ -60,7 +88,7 @@ export function subscribeDiagnostics(path: string, l: Listener): () => void { } export function installDiagnosticsEvents(): () => void { - const off = onIdeEvent("diagnostics", (e) => setFileDiagnostics(e.path, e.source, e.items)); + const off = onIdeEvent("diagnostics", (e) => storeDiagnostics(e.path, e.source, e.items)); // Another inspection profile (or edits to it) re-filters every open file. const offProfile = subscribeInspectionProfile(() => listeners.forEach((set) => set.forEach((l) => l()))); return () => { diff --git a/src/editor/lsp/inspectionWidget.ts b/src/editor/lsp/inspectionWidget.ts index 9fdcae162..a3a86afa4 100644 --- a/src/editor/lsp/inspectionWidget.ts +++ b/src/editor/lsp/inspectionWidget.ts @@ -14,13 +14,16 @@ interface Counts { error: number; warning: number; info: number; + /** Typo level (Proofreading), counted apart from weak warnings as in IDEA. */ + typo: number; } -function countsOf(view: EditorView): Counts { - const c: Counts = { error: 0, warning: 0, info: 0 }; +export function countsOf(view: EditorView): Counts { + const c: Counts = { error: 0, warning: 0, info: 0, typo: 0 }; forEachDiagnostic(view.state, (d) => { if (d.markClass?.includes("cm-lsp-unnecessary")) return; - if (d.severity === "error") c.error++; + if (d.markClass?.includes("cm-lsp-typo")) c.typo++; + else if (d.severity === "error") c.error++; else if (d.severity === "warning") c.warning++; else if (d.severity === "info") c.info++; }); @@ -57,7 +60,7 @@ class InspectionWidget { private render() { const c = countsOf(this.view); - const key = `${c.error}/${c.warning}/${c.info}`; + const key = `${c.error}/${c.warning}/${c.info}/${c.typo}`; if (key === this.key) return; this.key = key; const open = () => { @@ -67,9 +70,11 @@ class InspectionWidget { // no Problems tool window registered } }; - const total = c.error + c.warning + c.info; + const total = c.error + c.warning + c.info + c.typo; const summary = button( - total ? `${c.error} errors, ${c.warning} warnings, ${c.info} weak warnings — open Problems` : "No problems found", + total + ? `${c.error} errors, ${c.warning} warnings, ${c.info} weak warnings, ${c.typo} typos — open Problems` + : "No problems found", open, ); summary.classList.add("cm-openidea-inspection-summary"); @@ -85,6 +90,7 @@ class InspectionWidget { add(c.error, "error", "cm-openidea-inspection-error"); add(c.warning, "warning", "cm-openidea-inspection-warning"); add(c.info, "warning", "cm-openidea-inspection-weak"); + add(c.typo, "info", "cm-openidea-inspection-typo"); const children: HTMLElement[] = [summary]; if (total) { const prev = button(`Previous Highlighted Error${shortcutLabel("navigate.prevError") ? ` (${shortcutLabel("navigate.prevError")})` : ""}`, () => { @@ -149,6 +155,7 @@ const inspectionTheme = EditorView.theme({ ".cm-openidea-inspection-error .cm-openidea-icon": { color: "var(--danger)" }, ".cm-openidea-inspection-warning .cm-openidea-icon": { color: "var(--warning)" }, ".cm-openidea-inspection-weak .cm-openidea-icon": { color: "var(--fg-weak-warning, var(--fg-faint))" }, + ".cm-openidea-inspection-typo .cm-openidea-icon": { color: "var(--success)" }, ".cm-openidea-inspection-ok": { color: "var(--success)" }, }); diff --git a/src/editor/lsp/intentions.test.ts b/src/editor/lsp/intentions.test.ts index 7f0ae87fb..6c83df20b 100644 --- a/src/editor/lsp/intentions.test.ts +++ b/src/editor/lsp/intentions.test.ts @@ -28,7 +28,8 @@ vi.mock("@/platform/ipc", async (orig) => { return { ...actual, isTauri: () => true, commands: { ...actual.commands, lspResolveCodeAction } }; }); -const { intentionPreview } = await import("./intentions"); +const { intentionPreview, withInspectionControls } = await import("./intentions"); +const { setFileDiagnostics } = await import("./diagnostics"); const { lspDoc } = await import("./lsp"); const views: EditorView[] = []; @@ -37,6 +38,52 @@ afterEach(() => { vi.clearAllMocks(); }); +describe("inspection controls in ⌥↵ (issue #42)", () => { + const editorAt = (path: string) => { + const view = new EditorView({ + state: EditorState.create({ + doc: "import os\n", + selection: { anchor: 8 }, + extensions: [lspDoc.of({ doc: 1, path, language: "python", uiMode: null, readOnly: false })], + }), + }); + views.push(view); + return view; + }; + const unused = { + range: { start: { line: 0, col: 7 }, end: { line: 0, col: 9 } }, + severity: "warning" as const, + message: "`os` imported but unused", + source: "Ruff", + code: "F401", + }; + const labels = (items: { label: string }[]) => items.map((i) => i.label); + + it("gives the fixes of the one inspection at the caret its → menu", () => { + setFileDiagnostics("/p/one.py", "ruff", [unused]); + const view = editorAt("/p/one.py"); + const fix = { id: "0", label: "Remove unused import", icon: "bulb-fix", run: vi.fn() }; + const refactor = { id: "1", label: "Extract method", icon: "bulb", run: vi.fn() }; + const [withMenu, plain] = withInspectionControls(view, [fix, refactor]); + expect(withMenu!.submenu?.map((s) => ("label" in s ? s.label : "-"))).toEqual([ + "Edit inspection profile setting", + "Disable inspection", + ]); + expect(plain!.submenu).toBeUndefined(); + setFileDiagnostics("/p/one.py", "ruff", []); + }); + + it("offers an options row when the inspection has no fix", () => { + setFileDiagnostics("/p/two.py", "ruff", [unused]); + const view = editorAt("/p/two.py"); + const items = withInspectionControls(view, []); + expect(labels(items)).toEqual(["Inspection 'ruff F401' options"]); + expect(items[0]!.submenu).toHaveLength(2); + setFileDiagnostics("/p/two.py", "ruff", []); + expect(withInspectionControls(view, [])).toEqual([]); + }); +}); + describe("intention preview", () => { it("resolves the action once and shows its diff for this file", async () => { const view = new EditorView({ diff --git a/src/editor/lsp/intentions.ts b/src/editor/lsp/intentions.ts index e11a0959b..fb121fe42 100644 --- a/src/editor/lsp/intentions.ts +++ b/src/editor/lsp/intentions.ts @@ -18,6 +18,9 @@ import { serverBackoff } from "./availability"; import { iconNode } from "./iconMarkup"; import { editorPrefs } from "@/platform/editorSettings"; import { spellingIntentions } from "./spelling"; +import { inspectionsAt } from "./diagnostics"; +import { SPELLING_INSPECTION } from "@/platform/inspections"; +import { editInspectionSettings, intentionControls } from "@/platform/inspectionControls"; const isQuickFix = (a: CodeAction) => !!a.kind && (a.kind === "quickfix" || a.kind.startsWith("quickfix.")); @@ -170,25 +173,57 @@ export async function intentionPreview( return { kind: "text", text: full.command && !full.edit ? "Preview isn't available for this action" : "No changes in this file" }; } +/** + * IDEA's → menu on inspection-derived intentions: with one inspection reporting + * at the caret its quick fixes get its controls (Edit inspection profile + * setting, Disable inspection); otherwise each inspection there gets a row of + * its own. Typo fixes carry theirs already. + */ +export function withInspectionControls(view: EditorView, items: ListPopupItem[]): ListPopupItem[] { + const doc = docOf(view); + if (!doc) return items; + const inspections = inspectionsAt(doc.path, view.state.doc, view.state.selection.main.head).filter( + (i) => i.id !== SPELLING_INSPECTION, + ); + if (!inspections.length) return items; + const fixes = items.filter((i) => i.icon === "bulb-fix" && !i.submenu); + if (inspections.length === 1 && fixes.length) { + const submenu = intentionControls(inspections[0]!); + return items.map((i) => (fixes.includes(i) ? { ...i, submenu } : i)); + } + return [ + ...items, + ...inspections.map((inspection) => ({ + id: `inspection:${inspection.id}`, + label: `Inspection '${inspection.name}' options`, + icon: "settings", + run: () => editInspectionSettings(inspection.id), + submenu: intentionControls(inspection), + })), + ]; +} + /** ⌥↵ */ export async function showIntentions(view: EditorView) { try { // Typo fixes come first and need no language server. const local = spellingIntentions(view); let actions: CodeAction[] = []; + let failure: { error: unknown } | null = null; try { // Like IDEA, only applicable intentions are listed (servers also report disabled refactorings). actions = await fetchIntentions(view); } catch (e) { - if (!local.length) throw e; - } - if (!actions.length && !local.length) { - showHint(view, "No intentions available"); - return; + failure = { error: e }; } const sorted = sortCodeActions(actions); const resolved = new Map>(); - const items = [...local, ...actionItems(view, sorted, resolved)]; + const items = withInspectionControls(view, [...local, ...actionItems(view, sorted, resolved)]); + if (!items.length) { + if (failure) throw failure.error; + showHint(view, "No intentions available"); + return; + } openPopup({ kind: "list", anchor: caretAnchor(view, view.state.selection.main.head), diff --git a/src/editor/lsp/popupStore.ts b/src/editor/lsp/popupStore.ts index f098dab2e..e5276d6cc 100644 --- a/src/editor/lsp/popupStore.ts +++ b/src/editor/lsp/popupStore.ts @@ -2,6 +2,7 @@ import { create } from "zustand"; import type { EditorView } from "@codemirror/view"; import type { Location, Markup } from "@/bindings"; +import type { MenuItem } from "@/ui"; import type { WorkspaceEditSummary } from "./workspaceEdit"; export interface PopupPoint { @@ -24,6 +25,8 @@ export interface ListPopupItem { disabled?: string | null; shortcut?: string; run: () => void | Promise; + /** IDEA's right-arrow menu of the row (inspection options of a fix). */ + submenu?: MenuItem[]; /** Called when the row becomes the selected one (e.g. highlight what it would act on). */ onActive?: () => void; } diff --git a/src/editor/lsp/rename.ts b/src/editor/lsp/rename.ts index 680a20b30..8ba26c19b 100644 --- a/src/editor/lsp/rename.ts +++ b/src/editor/lsp/rename.ts @@ -105,6 +105,61 @@ export async function startRename(view: EditorView) { }); } +/** `name` with `[at, at + length)` replaced. */ +export function spliceName(name: string, at: number, length: number, replacement: string): string { + return name.slice(0, at) + replacement + name.slice(at + length); +} + +/** + * Replaces `part` of a declared name (a misspelt word of `checkpointer_pol`) + * through Rename, so every reference follows. Falls back to the syntax-based + * local rename; when neither can rename the symbol, says so and changes nothing + * (a lone text replacement would break the references). + */ +export async function renameNamePart(view: EditorView, part: Span, replacement: string) { + const doc = docOf(view); + if (!doc) return; + if (doc.readOnly) { + showHint(view, "The file is read-only"); + return; + } + const word = view.state.sliceDoc(part.from, part.to); + await whenSynced(view); + if (view.state.sliceDoc(part.from, part.to) !== word) return; + const pos = offsetToPos(view.state.doc, part.from); + const refuse = (why: string) => + showHint(view, `Cannot rename safely: ${why}. '${word}' was left unchanged`, { tone: "warning" }); + const viaSyntax = async (): Promise => { + const plan = await syntaxPlan(view, doc, pos); + if (!plan) return false; + const at = plan.occurrences + .map((o) => rangeToOffsets(view.state.doc, o.range)) + .find((o) => o.from <= part.from && part.to <= o.to); + if (!at) return false; + await commitSyntaxRename(view, doc, plan, spliceName(plan.symbol.name, part.from - at.from, word.length, replacement)); + return true; + }; + let range; + try { + range = await lspRequest(() => commands.lspPrepareRename(doc.doc, pos)); + } catch (e) { + if (isCancelled(e)) return; + if (!(await viaSyntax())) refuse("no language server can rename this symbol"); + return; + } + if (!range) { + if (!(await viaSyntax())) refuse("this element can't be renamed"); + return; + } + const { from, to } = rangeToOffsets(view.state.doc, range); + if (from > part.from || part.to > to) { + refuse("the word is not part of a renamable name"); + return; + } + const oldName = view.state.sliceDoc(from, to); + await finishRename(view, doc, pos, oldName, spliceName(oldName, part.from - from, word.length, replacement)); +} + /** This file's other occurrences of the symbol (document highlights with the same text). */ async function linkedOccurrences(view: EditorView, doc: number, pos: Pos, primary: Span, name: string): Promise { let highlights; diff --git a/src/editor/lsp/spelling.test.ts b/src/editor/lsp/spelling.test.ts index a654b0f59..a2fa3e83c 100644 --- a/src/editor/lsp/spelling.test.ts +++ b/src/editor/lsp/spelling.test.ts @@ -1,9 +1,10 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { EditorState, Text } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import type { Settings, Typo } from "@/bindings"; -const typos: Typo[] = [{ range: { start: { line: 0, col: 3 }, end: { line: 0, col: 9 } }, word: "mesage" }]; +let typos: Typo[] = []; +const commentTypo: Typo = { range: { start: { line: 0, col: 3 }, end: { line: 0, col: 9 } }, word: "mesage", context: "comment" }; const spellCheck = vi.fn(async () => ({ status: "ok" as const, data: typos })); const settingsSet = vi.fn(async (_scope: string, _key: string, value: string) => ({ status: "ok" as const, @@ -18,12 +19,16 @@ vi.mock("@/platform/ipc", async (orig) => { }; }); -const { spellingExtension, spellingIntentions } = await import("./spelling"); +const { spellingExtension, spellingIntentions, isNamePart } = await import("./spelling"); const { diagnosticsFor, toLintDiagnostics, diagnosticMarks } = await import("./diagnostics"); const { lspDoc } = await import("./lsp"); const { useProject } = await import("@/platform/project"); const views: EditorView[] = []; +const ALL = { code: true, literals: true, comments: true }; +beforeEach(() => { + typos = [commentTypo]; +}); afterEach(() => { views.splice(0).forEach((v) => v.destroy()); useProject.setState({ project: null, settings: null }); @@ -50,12 +55,17 @@ async function settle() { } describe("Typo inspection", () => { + it("renames a name part only to words that fit in an identifier", () => { + expect(["message", "menage", "Mesa", "ünïcode1"].every(isNamePart)).toBe(true); + expect(["me sage", "me-sage", "mes_age", ""].some(isNamePart)).toBe(false); + }); + it("reports typos at the Typo level and offers fixes at the caret", async () => { vi.useFakeTimers(); useProject.setState({ project: { root: "/p" } as never, settings: {} as never }); const view = editor("// mesage here"); await settle(); - expect(spellCheck).toHaveBeenCalledWith(7, expect.any(Number)); + expect(spellCheck).toHaveBeenCalledWith(7, ALL, expect.any(Number)); const [diag] = diagnosticsFor("/p/a.rs"); expect(diag).toMatchObject({ message: "Typo: In word 'mesage'", severity: "info", typo: true }); const lint = toLintDiagnostics(view.state.doc, diagnosticsFor("/p/a.rs")); @@ -68,6 +78,14 @@ describe("Typo inspection", () => { "Save 'mesage' to project-level dictionary", "Save 'mesage' to application-level dictionary", ]); + // IDEA's → menu: the dictionaries and the inspection's controls. + expect(spellingIntentions(view)[0]!.submenu?.map((i) => ("label" in i ? i.label : "-"))).toEqual([ + "Save 'mesage' to project-level dictionary", + "Save 'mesage' to application-level dictionary", + "-", + "Edit inspection profile setting", + "Disable inspection", + ]); view.dispatch({ selection: { anchor: 12 } }); expect(spellingIntentions(view)).toEqual([]); @@ -91,6 +109,30 @@ describe("Typo inspection", () => { expect(spellingIntentions(view)).toEqual([]); }); + it("renames declared names instead of replacing one occurrence", async () => { + vi.useFakeTimers(); + typos = [{ ...commentTypo, range: { start: { line: 0, col: 0 }, end: { line: 0, col: 6 } }, context: "code" }]; + useProject.setState({ project: { root: "/p" } as never, settings: {} as never }); + const view = editor("mesage = 1\nprint(mesage)"); + await settle(); + view.dispatch({ selection: { anchor: 2 } }); + expect(spellingIntentions(view)[0]!.label).toBe("Typo: Rename to…"); + }); + + it("checks only what the Typo options include, and re-checks when they change", async () => { + vi.useFakeTimers(); + const options = (o: Record) => + ({ inspections: { default: { "builtin:spelling": { options: o } } } }) as never; + useProject.setState({ project: { root: "/p" } as never, settings: options({ processCode: false, processLiterals: false }) }); + editor("// mesage"); + await settle(); + expect(spellCheck).toHaveBeenLastCalledWith(7, { code: false, literals: false, comments: true }, expect.any(Number)); + useProject.setState({ settings: options({ processComments: false }) }); + await settle(); + expect(spellCheck).toHaveBeenCalledTimes(2); + expect(spellCheck).toHaveBeenLastCalledWith(7, { code: true, literals: true, comments: false }, expect.any(Number)); + }); + it("keeps lint conversion of plain info diagnostics unchanged", () => { const doc = Text.of(["abc"]); const out = toLintDiagnostics(doc, [ diff --git a/src/editor/lsp/spelling.ts b/src/editor/lsp/spelling.ts index cffb6f41c..80ba1ca80 100644 --- a/src/editor/lsp/spelling.ts +++ b/src/editor/lsp/spelling.ts @@ -1,21 +1,32 @@ /** * Proofreading › Typo: misspelt words of comments, strings and declared names * (the backend extracts and checks them off the UI thread), reported as - * diagnostics of the `spelling` producer at the Typo level, with the quick - * fixes "Change to…" and "Save 'word' to project / application dictionary". + * diagnostics of the `spelling` producer at the Typo level. Quick fixes: + * "Change to…" (comments, literals, text: the word in place) or "Rename to…" + * (a declared name: through Rename, so references follow), "Save 'word' to + * project / application dictionary", and the inspection's options (→). */ import { StateEffect, StateField, type EditorState, type Extension } from "@codemirror/state"; import { EditorView, ViewPlugin } from "@codemirror/view"; import { whenSynced } from "@/editor/api"; import { call, commands, isCancelled, isTauri, nextRequestId, type RequestId } from "@/platform/ipc"; import { useProject } from "@/platform/project"; -import { inspectionProfile, SPELLING_INSPECTION, SPELLING_PRODUCER } from "@/platform/inspections"; -import type { Settings } from "@/bindings"; +import { + inspectionOption, + inspectionProfile, + SPELLING_INSPECTION, + SPELLING_OPTIONS, + SPELLING_PRODUCER, +} from "@/platform/inspections"; +import { intentionControls } from "@/platform/inspectionControls"; +import type { Settings, SpellScope, TypoContext } from "@/bindings"; +import type { MenuItem } from "@/ui"; import { setFileDiagnostics } from "./diagnostics"; import { docOf } from "./lsp"; import { rangeToOffsets } from "./positions"; import { showHint } from "./hint"; import { caretAnchor, openPopup, type ListPopupItem } from "./popupStore"; +import { renameNamePart } from "./rename"; const CHECK_DELAY = 800; @@ -23,6 +34,7 @@ interface PlacedTypo { from: number; to: number; word: string; + context: TypoContext; } const setTypos = StateEffect.define(); @@ -41,6 +53,13 @@ const typosField = StateField.define({ const spellingEnabled = () => inspectionProfile()[SPELLING_INSPECTION]?.enabled !== false; +/** What the Typo inspection's options (profile in effect) have checked. */ +export function spellScope(): SpellScope { + const profile = inspectionProfile(); + const on = (key: string) => inspectionOption(profile, SPELLING_INSPECTION, key); + return { code: on(SPELLING_OPTIONS.code), literals: on(SPELLING_OPTIONS.literals), comments: on(SPELLING_OPTIONS.comments) }; +} + const spellingOf = (s: Settings | null) => s?.spelling; /** Lowercased words of the project and application dictionaries. */ @@ -74,6 +93,20 @@ function replaceWord(view: EditorView, typo: PlacedTypo, replacement: string) { }); } +/** + * The correction: a declared name is renamed with its references (IDEA's + * "Typo: Rename to"); words of comments, literals and text change in place. + */ +export function applyCorrection(view: EditorView, typo: PlacedTypo, replacement: string): void | Promise { + if (typo.context === "code") return renameNamePart(view, typo, replacement); + replaceWord(view, typo, replacement); +} + +/** Letters and digits only: what can replace one word of an identifier. */ +export function isNamePart(word: string): boolean { + return /^[\p{L}\p{N}]+$/u.test(word); +} + async function changeTo(view: EditorView, typo: PlacedTypo) { let suggestions: string[]; try { @@ -81,6 +114,9 @@ async function changeTo(view: EditorView, typo: PlacedTypo) { } catch { suggestions = []; } + const rename = typo.context === "code"; + // A name part can only become another name part: "me sage" or "me-sage" would break the code. + if (rename) suggestions = suggestions.filter(isNamePart); if (!suggestions.length) { showHint(view, `No suggestions for '${typo.word}'`); return; @@ -88,8 +124,8 @@ async function changeTo(view: EditorView, typo: PlacedTypo) { openPopup({ kind: "list", anchor: caretAnchor(view, typo.from), - title: `Change '${typo.word}' to`, - items: suggestions.map((s, i) => ({ id: `${i}`, label: s, run: () => replaceWord(view, typo, s) })), + title: `${rename ? "Rename" : "Change"} '${typo.word}' to`, + items: suggestions.map((s, i) => ({ id: `${i}`, label: s, run: () => applyCorrection(view, typo, s) })), }); } @@ -101,8 +137,7 @@ export function spellingIntentions(view: EditorView): ListPopupItem[] { const typo = typoAt(view.state, sel.head); if (!typo) return []; const hasProject = !!useProject.getState().project; - return [ - { id: "spelling.change", label: "Typo: Change to…", icon: "bulb-fix", emphasis: true, run: () => changeTo(view, typo) }, + const saves: ListPopupItem[] = [ ...(hasProject ? [ { @@ -120,6 +155,22 @@ export function spellingIntentions(view: EditorView): ListPopupItem[] { run: () => saveToDictionary(typo.word, "ide"), }, ]; + const submenu: MenuItem[] = [ + ...saves.map((s) => ({ id: s.id, label: s.label, verbatim: true, run: () => void s.run() })), + { kind: "separator" }, + ...intentionControls({ id: SPELLING_INSPECTION, name: "Typo" }), + ]; + return [ + { + id: "spelling.change", + label: typo.context === "code" ? "Typo: Rename to…" : "Typo: Change to…", + icon: "bulb-fix", + emphasis: true, + run: () => changeTo(view, typo), + submenu, + }, + ...saves.map((s) => ({ ...s, submenu })), + ]; } /** Checks the file after edits (debounced), dictionary and profile changes. */ @@ -150,7 +201,7 @@ export function spellingExtension(): Extension { try { await whenSynced(view); if (mine !== seq) return; - const typos = await call(commands.spellCheck(doc.doc, id)); + const typos = await call(commands.spellCheck(doc.doc, spellScope(), id)); if (mine !== seq) return; const text = view.state.doc; setFileDiagnostics( @@ -164,7 +215,7 @@ export function spellingExtension(): Extension { code: null, })), ); - const placed = typos.map((t) => ({ ...rangeToOffsets(text, t.range), word: t.word })); + const placed = typos.map((t) => ({ ...rangeToOffsets(text, t.range), word: t.word, context: t.context })); view.dispatch({ effects: setTypos.of(placed) }); } catch (e) { if (!isCancelled(e)) console.debug("[spelling]", e); @@ -177,10 +228,11 @@ export function spellingExtension(): Extension { timer = setTimeout(() => void run(), delay); }; schedule(200); - let lastConfig = JSON.stringify([spellingOf(useProject.getState().settings), spellingEnabled()]); + const config = (s: Settings | null) => JSON.stringify([spellingOf(s), spellingEnabled(), spellScope()]); + let lastConfig = config(useProject.getState().settings); const offSettings = useProject.subscribe((s, prev) => { - if (s.settings === prev.settings) return; - const next = JSON.stringify([spellingOf(s.settings), spellingEnabled()]); + if (s.settings === prev.settings && s.project === prev.project) return; + const next = config(s.settings); if (next === lastConfig) return; lastConfig = next; schedule(0); diff --git a/src/editor/lsp/syntaxFallback.test.ts b/src/editor/lsp/syntaxFallback.test.ts index 5ad76d982..5524891c1 100644 --- a/src/editor/lsp/syntaxFallback.test.ts +++ b/src/editor/lsp/syntaxFallback.test.ts @@ -18,6 +18,7 @@ const cmd = { syntaxIdentifiers: vi.fn((): R => ok([])), syntaxErrors: vi.fn((): R => ok({ errors: [], truncated: false })), findSymbols: vi.fn((): R => ok([])), + applyWorkspaceEdit: vi.fn((): R => ok(null)), }; vi.mock("@/platform/ipc", async (orig) => { @@ -31,7 +32,7 @@ provideEditorService({ openFile } as never); const fb = await import("./syntaxFallback"); const { gotoTargets, navigationExtension, highlightUsages } = await import("./navigation"); -const { startRename } = await import("./rename"); +const { startRename, renameNamePart } = await import("./rename"); const { lspDoc } = await import("./lsp"); const { useLspPopup, closePopup } = await import("./popupStore"); @@ -345,6 +346,36 @@ describe("rename wiring", () => { expect(document.querySelector(".cm-lsp-hint")?.textContent).toContain("only local variables"); }); + describe("Typo: Rename to (issue #42)", () => { + it("renames the whole name through the server, references included", async () => { + cmd.lspPrepareRename.mockReturnValueOnce(ok(r(0, 0, 12))); + const edits = [r(0, 0, 12), r(1, 6, 18)].map((range) => ({ range, new_text: "message_count" })); + const edit = { files: [{ kind: "edit", path: "/p/a.ts", edits }] }; + cmd.lspRename.mockReturnValueOnce(ok(edit)); + const view = editor("mesage_count = 1\nprint(mesage_count)", "python", 0); + await renameNamePart(view, { from: 0, to: 6 }, "message"); + expect(cmd.lspRename).toHaveBeenCalledWith(7, { line: 0, col: 0 }, "message_count"); + expect(cmd.applyWorkspaceEdit).toHaveBeenCalledWith(edit); + // Nothing was replaced locally: the server's edit covers every reference. + expect(view.state.doc.toString()).toBe("mesage_count = 1\nprint(mesage_count)"); + }); + + it("falls back to the syntax rename of a local, references included", async () => { + cmd.syntaxLocals.mockReturnValueOnce(ok(local({ name: "mesage", definition: r(0, 0, 6), references: [r(1, 6, 12)] }))); + const view = editor("mesage = 'hello'\nprint(mesage)", "python", 0); + await renameNamePart(view, { from: 0, to: 6 }, "message"); + expect(view.state.doc.toString()).toBe("message = 'hello'\nprint(message)"); + }); + + it("changes nothing when the symbol can't be renamed safely", async () => { + cmd.syntaxLocals.mockReturnValueOnce(ok({ status: "notLocal", name: "mesage", range: r(0, 0, 6), reason: "topLevel" })); + const view = editor("mesage = 'hello'\nprint(mesage)", "python", 0); + await renameNamePart(view, { from: 0, to: 6 }, "message"); + expect(view.state.doc.toString()).toBe("mesage = 'hello'\nprint(mesage)"); + expect(document.querySelector(".cm-lsp-hint")?.textContent).toContain("Cannot rename safely"); + }); + }); + it("respects a server refusal over valid code, but falls back over broken code", async () => { cmd.lspPrepareRename.mockReturnValueOnce(ok(null)); const view = editor("const x = 1;", "typescript", 6); diff --git a/src/features/analysis/InspectionView.tsx b/src/features/analysis/InspectionView.tsx index 4b35509e3..71474b945 100644 --- a/src/features/analysis/InspectionView.tsx +++ b/src/features/analysis/InspectionView.tsx @@ -8,6 +8,8 @@ import { create } from "zustand"; import { executeAction } from "@/platform/actions"; import { shortcutLabel } from "@/platform/keymap"; import { basename, relativeDir, useProject } from "@/platform/project"; +import { diagnosticInspection, producerOf, type InspectionRef } from "@/platform/inspections"; +import { problemControls } from "@/platform/inspectionControls"; import { editorService, useOccurrenceNavigator } from "@/workbench/contributions"; import { Button, @@ -178,6 +180,20 @@ function nodeProblems(n: Node): InspectionProblem[] { return n.kind === "problem" ? [n.p] : n.children.flatMap(nodeProblems); } +/** The one inspection all of the node's problems come from, or null. */ +function nodeInspection(n: Node): InspectionRef | null { + let found: InspectionRef | null = null; + for (const p of nodeProblems(n)) { + const ref = diagnosticInspection(producerOf(p.d), p.d); + if (!ref || (found && found.id !== ref.id)) return null; + found = ref; + } + return found; +} + +const inspectionMenu = (inspection: InspectionRef | null): MenuItem[] => + inspection ? [{ kind: "separator" }, ...problemControls(inspection)] : []; + function open(p: InspectionProblem, focus = true): Promise { return editorService().openFile(p.path, { selection: { line: p.d.range.start.line, col: p.d.range.start.col }, @@ -380,6 +396,8 @@ export function InspectionView() { ] : []), { id: "exclude", label: isExcluded(n) ? "Include" : "Exclude", shortcut: "⌫", run: () => exclude(n) }, + // Exclude hides problems from these results; Disable Inspection turns the check off in the profile. + ...inspectionMenu(nodeInspection(n)), ]); }} activateExpandable={false} diff --git a/src/features/code-insight/ProblemsView.tsx b/src/features/code-insight/ProblemsView.tsx index 5b60883a4..3d86949be 100644 --- a/src/features/code-insight/ProblemsView.tsx +++ b/src/features/code-insight/ProblemsView.tsx @@ -7,6 +7,8 @@ import { useEditorRegistry } from "@/editor/api"; import { executeAction } from "@/platform/actions"; import { formatBinding, hasPrimaryModifier, hostBinding, shortcutLabel } from "@/platform/keymap"; import { basename, relativeDir, useProject } from "@/platform/project"; +import { diagnosticInspection, producerOf, type InspectionRef } from "@/platform/inspections"; +import { problemControls } from "@/platform/inspectionControls"; import { editorService, useOccurrenceNavigator } from "@/workbench/contributions"; import { EmptyState, @@ -30,6 +32,7 @@ import { DEFAULT_PROBLEM_ORDER, countSeverities, fileDiagnostics, + producerFor, sortProblems, useProblems, type ProblemOrder, @@ -109,6 +112,14 @@ type Node = | { kind: "file"; key: string; path: string; items: Diagnostic[] } | { kind: "diag"; key: string; path: string; d: Diagnostic }; +/** The inspection that reported `d` (for Disable inspection / Change Severity). */ +export function diagInspection(map: ProblemsMap, path: string, d: Diagnostic): InspectionRef | null { + return diagnosticInspection(producerFor(map, path, d) ?? producerOf(d), d); +} + +const inspectionMenu = (inspection: InspectionRef | null): MenuItem[] => + inspection ? [{ kind: "separator" }, ...problemControls(inspection)] : []; + const plural = (n: number, word: string) => (n ? `${n} ${word}${n === 1 ? "" : "s"}` : ""); const SEVERITY_ICON: Record = { error: "error", warning: "warning", info: "info", hint: "info" }; @@ -374,6 +385,7 @@ export function ProblemsView() { disabled: n.kind !== "diag", run: () => copy(n), }, + ...inspectionMenu(n.kind === "diag" ? diagInspection(map, n.path, n.d) : null), ]); }} activateExpandable={false} diff --git a/src/features/code-insight/problemsStore.test.ts b/src/features/code-insight/problemsStore.test.ts index 2428ad8c8..2eaf06603 100644 --- a/src/features/code-insight/problemsStore.test.ts +++ b/src/features/code-insight/problemsStore.test.ts @@ -81,3 +81,25 @@ describe("profiledProblems", () => { expect(fileDiagnostics(profiledProblems(raw, {}), "/b")).toHaveLength(1); }); }); + +describe("local inspections (issue #42)", () => { + it("reach Problems and clear there too, with their inspection known", async () => { + const { installProblemsEvents, producerFor } = await import("./problemsStore"); + const { setFileDiagnostics } = await import("@/editor/lsp/diagnostics"); + const { diagnosticInspection } = await import("@/platform/inspections"); + clearProblems(); + const off = installProblemsEvents(); + try { + setFileDiagnostics("/p/a.py", "spelling", [{ ...d(0, "info"), message: "Typo: In word 'mesage'", source: "Spelling" }]); + const [typo] = fileDiagnostics(useProblems.getState().map, "/p/a.py"); + expect(typo).toMatchObject({ message: "Typo: In word 'mesage'", typo: true }); + const producer = producerFor(useProblems.getState().map, "/p/a.py", typo!); + expect(producer).toBe("spelling"); + expect(diagnosticInspection(producer!, typo!)?.id).toBe("builtin:spelling"); + setFileDiagnostics("/p/a.py", "spelling", []); + expect(useProblems.getState().map.size).toBe(0); + } finally { + off(); + } + }); +}); diff --git a/src/features/code-insight/problemsStore.ts b/src/features/code-insight/problemsStore.ts index 946a1934a..87df26532 100644 --- a/src/features/code-insight/problemsStore.ts +++ b/src/features/code-insight/problemsStore.ts @@ -6,6 +6,7 @@ import { create } from "zustand"; import type { Diagnostic, Severity } from "@/bindings"; import { onIdeEvent } from "@/platform/ipc"; +import { onLocalDiagnostics } from "@/platform/localDiagnostics"; import { applyProfile, inspectionProfile, @@ -53,6 +54,12 @@ export function fileDiagnostics(map: ProblemsMap, path: string): Diagnostic[] { ); } +/** The producer (inspection) that reported `d`, one of `path`'s problems in `map`. */ +export function producerFor(map: ProblemsMap, path: string, d: Diagnostic): string | null { + for (const [source, items] of map.get(path) ?? []) if (items.includes(d)) return source; + return null; +} + export interface ProblemOrder { /** Errors before warnings before infos (IDEA's default). */ bySeverity: boolean; @@ -130,11 +137,14 @@ export function clearProblems() { export function installProblemsEvents(): () => void { const off = onIdeEvent("diagnostics", (e) => applyDiagnosticsEvent(e.path, e.source, e.items)); + // Typo, file references, SQL: inspections the editor runs itself. + const offLocal = onLocalDiagnostics(applyDiagnosticsEvent); const offProfile = subscribeInspectionProfile(() => useProblems.setState((s) => ({ map: profiledProblems(s.raw, inspectionProfile()) })), ); return () => { off(); + offLocal(); offProfile(); }; } diff --git a/src/features/settings/__tests__/inspections.test.ts b/src/features/settings/__tests__/inspections.test.ts index 50b3cf0d0..f3e408f3f 100644 --- a/src/features/settings/__tests__/inspections.test.ts +++ b/src/features/settings/__tests__/inspections.test.ts @@ -10,8 +10,11 @@ import { renameProfile, setLevel, withProfileOverrides, + pathToInspection, + setOption, type InspectionNode, } from "../pages/editor/inspectionsModel"; +import { splitPageTarget } from "../model/staging"; import { validateInspections } from "@/platform/inspections"; import { NATURAL_LANGUAGES_PAGE, SPELLING_PAGE } from "../pages/editor/spelling"; import { INSPECTIONS_PAGE } from "../pages/editor/inspections"; @@ -123,3 +126,24 @@ describe("inspection profile management", () => { expect(SPELLING_PAGE.keywords).toContain("accepted words"); }); }); + +describe("inspection options and focus (issue #42)", () => { + it("stores Typo options apart from defaults and keeps them through validation", () => { + const off = setOption({}, "builtin:spelling", "processCode", false); + expect(off).toEqual({ "builtin:spelling": { options: { processCode: false } } }); + expect(validateInspections({ default: off }).default).toEqual(off); + expect(setOption(off, "builtin:spelling", "processCode", true)).toEqual({}); + }); + + it("opens Settings on an inspection: page#id, ancestors to expand", () => { + expect(splitPageTarget("editor.inspections#lsp:ruff:F401")).toEqual({ + pageId: "editor.inspections", + focus: "lsp:ruff:F401", + }); + expect(splitPageTarget("editor.general")).toEqual({ pageId: "editor.general", focus: null }); + const tree = buildInspectionTree([], []); + const path = pathToInspection(tree, "builtin:spelling"); + expect(path).toEqual(["group:Proofreading", "Proofreading/builtin:spelling"]); + expect(pathToInspection(tree, "nope")).toBeNull(); + }); +}); diff --git a/src/features/settings/model/staging.ts b/src/features/settings/model/staging.ts index 2a65cc79c..1591da3c5 100644 --- a/src/features/settings/model/staging.ts +++ b/src/features/settings/model/staging.ts @@ -21,6 +21,8 @@ export interface StagedChange { export interface SettingsDialogState { open: boolean; pageId: string; + /** What the page should select when opened as `page#focus` (an inspection id). */ + focus: string | null; query: string; /** Effective settings when the dialog opened / last applied. */ base: ValidSettings; @@ -40,6 +42,7 @@ export const DEFAULT_PAGE = "appearance"; export const useSettingsDialog = create(() => ({ open: false, pageId: DEFAULT_PAGE, + focus: null, query: "", base: validateSettings(undefined), globalBase: validateSettings(undefined), @@ -52,12 +55,20 @@ export const useSettingsDialog = create(() => ({ const stageKey = (scope: Scope, key: string) => `${scope}|${key}`; +/** `page#focus` → the page and what to select on it. */ +export function splitPageTarget(target: string): { pageId: string; focus: string | null } { + const i = target.indexOf("#"); + return i < 0 ? { pageId: target, focus: null } : { pageId: target.slice(0, i), focus: target.slice(i + 1) || null }; +} + export function openSettings(pageId?: string, opts: { newProjects?: boolean } = {}) { const s = useSettingsDialog.getState(); const base = validateSettings(useProject.getState().settings); + const target = pageId ? splitPageTarget(pageId) : { pageId: s.pageId, focus: null }; useSettingsDialog.setState({ open: true, - pageId: pageId ?? s.pageId, + pageId: target.pageId, + focus: target.focus, query: "", base, globalBase: base, @@ -95,8 +106,9 @@ export function closeSettings() { useSettingsDialog.setState({ open: false, staged: {}, error: null, saving: false }); } +/** Shows `pageId` (`page#focus` also selects `focus` on it). */ export function selectPage(pageId: string) { - useSettingsDialog.setState({ pageId }); + useSettingsDialog.setState(splitPageTarget(pageId)); } export function setQuery(query: string) { diff --git a/src/features/settings/pages/editor/Inspections.module.css b/src/features/settings/pages/editor/Inspections.module.css index dcde60517..bcbeefd04 100644 --- a/src/features/settings/pages/editor/Inspections.module.css +++ b/src/features/settings/pages/editor/Inspections.module.css @@ -97,6 +97,22 @@ padding-top: 10px; border-top: 1px solid var(--border); } +.options { + display: flex; + flex-direction: column; + gap: 6px; + margin: 0; + padding: 10px 0 0; + border: none; + border-top: 1px solid var(--border); + min-width: 0; +} +.options legend { + float: left; + width: 100%; + padding: 0 0 4px; + color: var(--fg-muted); +} .levelSelect { min-width: 160px; } diff --git a/src/features/settings/pages/editor/inspections.tsx b/src/features/settings/pages/editor/inspections.tsx index 926a37bb3..f57b53cbf 100644 --- a/src/features/settings/pages/editor/inspections.tsx +++ b/src/features/settings/pages/editor/inspections.tsx @@ -5,12 +5,14 @@ * chosen one is in effect for the project (editor highlighting, Problems, * Inspect Code). */ -import { useEffect, useId, useMemo, useState, type MouseEvent } from "react"; +import { useEffect, useId, useMemo, useRef, useState, type MouseEvent } from "react"; import clsx from "clsx"; import { commands, isTauri } from "@/platform/ipc"; import { + BUILTIN_INSPECTIONS, copyProfileName, effectiveProfileId, + inspectionOption, listProfiles, overridesOf, profileLabel, @@ -36,6 +38,7 @@ import { Tree, showContextMenu, type MenuItem, + type TreeHandle, } from "@/ui"; import { useSetting } from "../../components/context"; import { useSettingsDialog } from "../../model/staging"; @@ -51,7 +54,9 @@ import { deleteProfile, filterInspectionTree, isEnabled, + pathToInspection, setEnabled, + setOption, renameProfile, setLevel, withProfileOverrides, @@ -137,6 +142,28 @@ function InspectionsPage() { const expanded = useMemo(() => (filtering ? expandableKeys(roots) : expandedKeys), [filtering, roots, expandedKeys]); const [selected, setSelected] = useState(null); + // Opened as `editor.inspections#` (Edit inspection profile setting): select that inspection. + const focus = useSettingsDialog((s) => s.focus); + const treeRef = useRef(null); + const reveal = useRef(null); + useEffect(() => { + const path = focus ? pathToInspection(tree, focus) : null; + if (!path) return; + // Expand first; the effect below selects the row once it is rendered. + const frame = requestAnimationFrame(() => { + reveal.current = path[path.length - 1]!; + setQuery(""); + setExpandedKeys((prev) => new Set([...prev, ...path.slice(0, -1)])); + useSettingsDialog.setState({ focus: null }); + }); + return () => cancelAnimationFrame(frame); + }, [focus, tree]); + useEffect(() => { + if (!reveal.current) return; + treeRef.current?.reveal(reveal.current); + reveal.current = null; + }, [expanded]); + const profiles = listProfiles(settings, projectAvailable); const profileOptions = profiles.map((p) => ({ value: p.id, @@ -249,6 +276,7 @@ function InspectionsPage() { > aria-label="Inspections" + handleRef={treeRef} roots={roots} getKey={(n) => n.key} getLabel={(n) => n.name} @@ -434,10 +462,40 @@ function Details({ /> )} + {id && } ); } +/** The inspection's own options (Typo: Process code / literals / comments). */ +function InspectionOptions({ + id, + overrides, + onChange, +}: { + id: string; + overrides: ProfileOverrides; + onChange: (o: ProfileOverrides) => void; +}) { + const specs = BUILTIN_INSPECTIONS.find((b) => b.id === id)?.options; + if (!specs?.length) return null; + return ( +
+ + Options + + {specs.map((o) => ( + onChange(setOption(overrides, id, o.key, on))} + /> + ))} +
+ ); +} + export const INSPECTIONS_PAGE: PageSpec = { id: "editor.inspections", title: "Inspections", diff --git a/src/features/settings/pages/editor/inspectionsModel.ts b/src/features/settings/pages/editor/inspectionsModel.ts index 342a3c45a..358a20dd3 100644 --- a/src/features/settings/pages/editor/inspectionsModel.ts +++ b/src/features/settings/pages/editor/inspectionsModel.ts @@ -172,6 +172,25 @@ export function setLevel(o: ProfileOverrides, id: string, level: InspectionLevel return withOverride(o, id, { severity: level }); } +/** Sets option `key` of inspection `id` (dropped again when back to its default). */ +export function setOption(o: ProfileOverrides, id: string, key: string, value: boolean): ProfileOverrides { + const fallback = BUILTIN_INSPECTIONS.find((b) => b.id === id)?.options?.find((x) => x.key === key)?.default ?? true; + const options: Record = { ...o[id]?.options }; + if (value === fallback) delete options[key]; + else options[key] = value; + return withOverride(o, id, { options: Object.keys(options).length ? options : undefined }); +} + +/** Keys of `id`'s node and its ancestors in `nodes`, outermost first; null when absent. */ +export function pathToInspection(nodes: readonly InspectionNode[], id: string): string[] | null { + for (const n of nodes) { + if (n.id === id) return [n.key]; + const below = pathToInspection(n.children, id); + if (below) return [n.key, ...below]; + } + return null; +} + export const AS_REPORTED = ""; export const LEVEL_OPTIONS = [ @@ -195,6 +214,9 @@ export const INSPECTION_KEYWORDS = [ "delete profile", "spelling", "proofreading", + "process code", + "process literals", + "process comments", ...BUILTIN_INSPECTIONS.map((b) => b.name), ...INSPECTION_LEVELS.map((l) => l.label), ]; diff --git a/src/platform/inspectionControls.test.ts b/src/platform/inspectionControls.test.ts new file mode 100644 index 000000000..17d71ebe9 --- /dev/null +++ b/src/platform/inspectionControls.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Settings } from "@/bindings"; + +const settingsSet = vi.fn(async (_scope: string, _key: string, _value: string) => ({ + status: "ok" as const, + data: {} as Settings, +})); +const executeAction = vi.fn(async () => true); +vi.mock("./ipc", async (orig) => { + const actual = await orig(); + return { ...actual, commands: { ...actual.commands, settingsSet } }; +}); +vi.mock("./actions", async (orig) => ({ ...(await orig()), executeAction })); + +const { disableInspection, intentionControls, patchOverride, problemControls, setInspectionOption, setInspectionSeverity } = + await import("./inspectionControls"); +const { diagnosticInspection, inspectionOption, SPELLING_INSPECTION } = await import("./inspections"); +const { useProject } = await import("./project"); + +afterEach(() => { + useProject.setState({ project: null, settings: null }); + vi.clearAllMocks(); +}); + +const written = () => { + const [scope, key, value] = settingsSet.mock.calls.at(-1)!; + return { scope, key, value: JSON.parse(value) }; +}; + +describe("inspection controls", () => { + it("names the inspection a diagnostic belongs to", () => { + expect(diagnosticInspection("spelling", { code: null })).toEqual({ id: SPELLING_INSPECTION, name: "Typo" }); + expect(diagnosticInspection("ruff", { code: "F401" })).toEqual({ id: "lsp:ruff:F401", name: "ruff F401" }); + expect(diagnosticInspection("pyright", { code: null })).toEqual({ id: "lsp:pyright", name: "pyright" }); + expect(diagnosticInspection("", { code: null })).toBeNull(); + }); + + it("writes to the profile in effect, at its level", async () => { + useProject.setState({ project: null, settings: { inspections: { default: { x: { severity: "error" } } } } as never }); + await disableInspection(SPELLING_INSPECTION); + expect(written()).toEqual({ + scope: "global", + key: "inspections.default", + value: { x: { severity: "error" }, [SPELLING_INSPECTION]: { enabled: false } }, + }); + + useProject.setState({ project: { root: "/p" } as never, settings: { inspections: { profile: "project" } } as never }); + await setInspectionSeverity("lsp:ruff:F401", "weakWarning"); + expect(written()).toEqual({ scope: "project", key: "inspections.project", value: { "lsp:ruff:F401": { severity: "weakWarning" } } }); + + useProject.setState({ + project: { root: "/p" } as never, + settings: { inspections: { profile: "ide:strict", ideProfiles: { strict: { name: "Strict", overrides: {} } } } } as never, + }); + await setInspectionOption(SPELLING_INSPECTION, "processCode", false); + expect(written()).toEqual({ + scope: "global", + key: "inspections.ideProfiles", + value: { strict: { name: "Strict", overrides: { [SPELLING_INSPECTION]: { options: { processCode: false } } } } }, + }); + }); + + it("drops entries that end up empty", () => { + expect(patchOverride({ a: { severity: "error" } }, "a", (o) => ({ ...o, severity: undefined }))).toEqual({}); + }); + + it("reads options with their defaults", () => { + expect(inspectionOption({}, SPELLING_INSPECTION, "processCode")).toBe(true); + expect(inspectionOption({ [SPELLING_INSPECTION]: { options: { processCode: false } } }, SPELLING_INSPECTION, "processCode")).toBe(false); + }); + + it("offers IDEA's menu rows, opening Settings on the inspection", () => { + const ref = { id: "lsp:ruff:F401", name: "ruff F401" }; + const rows = intentionControls(ref); + expect(rows.map((r) => ("label" in r ? r.label : "-"))).toEqual(["Edit inspection profile setting", "Disable inspection"]); + (rows[0] as { run: () => void }).run(); + expect(executeAction).toHaveBeenCalledWith("file.settings", "editor.inspections#lsp:ruff:F401"); + + useProject.setState({ settings: { inspections: { default: { "lsp:ruff:F401": { severity: "error" } } } } as never }); + const menu = problemControls(ref); + expect(menu.map((r) => ("label" in r ? r.label : "-"))).toEqual([ + "Disable Inspection 'ruff F401'", + "Change Severity", + "Edit Inspection Profile Setting…", + ]); + const severity = menu[1] as { submenu: { label: string; checked?: boolean }[] }; + expect(severity.submenu.filter((s) => s.checked).map((s) => s.label)).toEqual(["Error"]); + }); +}); diff --git a/src/platform/inspectionControls.ts b/src/platform/inspectionControls.ts new file mode 100644 index 000000000..9b5cf3ee8 --- /dev/null +++ b/src/platform/inspectionControls.ts @@ -0,0 +1,138 @@ +/** + * IDEA's inspection controls, offered wherever a problem is shown (⌥↵ submenu + * of an inspection's fixes, Problems, Inspection Results): Edit inspection + * profile setting, Disable inspection, change its severity. Every change is + * written to the inspection profile in effect, at the level it is stored. + */ +import { executeAction } from "./actions"; +import { call, commands } from "./ipc"; +import { useProject } from "./project"; +import { + INSPECTION_LEVELS, + activeProfileId, + inspectionProfile, + inspectionSettings, + profileLocation, + type InspectionLevel, + type InspectionOverride, + type InspectionRef, + type ProfileOverrides, +} from "./inspections"; + +/** Menu rows (structurally `@/ui` `MenuItem`s). */ +export type ControlItem = + | { kind: "separator" } + | { + id?: string; + label: string; + verbatim?: boolean; + icon?: string; + checked?: boolean; + run?: () => void; + submenu?: ControlItem[]; + }; + +/** `overrides` with `patch` applied to inspection `id` (empty entries removed). */ +export function patchOverride( + overrides: ProfileOverrides, + id: string, + patch: (o: InspectionOverride) => InspectionOverride, +): Record { + const next: Record = { ...overrides }; + const entry: Record = { ...patch({ ...overrides[id] }) }; + for (const k of Object.keys(entry)) if (entry[k] === undefined) delete entry[k]; + if (Object.keys(entry).length) next[id] = entry as InspectionOverride; + else delete next[id]; + return next; +} + +/** Changes inspection `id` in the profile in effect and stores it. */ +export async function updateInspection(id: string, patch: (o: InspectionOverride) => InspectionOverride) { + const s = inspectionSettings(); + const { level, key } = profileLocation(activeProfileId()); + const scope = level === "project" ? "project" : "global"; + let settingKey: string; + let value: unknown; + if (key === null) { + settingKey = level === "project" ? "inspections.project" : "inspections.default"; + value = patchOverride(level === "project" ? s.project : s.default, id, patch); + } else { + const field = level === "project" ? "projectProfiles" : "ideProfiles"; + const profile = s[field][key]; + if (!profile) return; + settingKey = `inspections.${field}`; + value = { ...s[field], [key]: { ...profile, overrides: patchOverride(profile.overrides, id, patch) } }; + } + const settings = await call(commands.settingsSet(scope, settingKey, JSON.stringify(value))); + useProject.setState({ settings }); +} + +export const disableInspection = (id: string) => updateInspection(id, (o) => ({ ...o, enabled: false })); + +/** `undefined` restores the inspection's default level. */ +export const setInspectionSeverity = (id: string, level: InspectionLevel | undefined) => + updateInspection(id, (o) => ({ ...o, severity: level })); + +export const setInspectionOption = (id: string, option: string, value: boolean) => + updateInspection(id, (o) => ({ ...o, options: { ...o.options, [option]: value } })); + +/** Settings › Editor › Inspections with the inspection selected. */ +export function editInspectionSettings(id: string) { + void executeAction("file.settings", `editor.inspections#${id}`); +} + +const report = (p: Promise) => void p.catch((e) => console.warn("[inspections]", e)); + +/** ⌥↵ submenu of an inspection's fix (IDEA's right-arrow menu). */ +export function intentionControls(inspection: InspectionRef): ControlItem[] { + return [ + { + id: "inspection.edit", + label: "Edit inspection profile setting", + icon: "settings", + run: () => editInspectionSettings(inspection.id), + }, + { + id: "inspection.disable", + label: "Disable inspection", + run: () => report(disableInspection(inspection.id)), + }, + ]; +} + +/** Problems / Inspection Results context menu rows for a problem's inspection. */ +export function problemControls(inspection: InspectionRef): ControlItem[] { + const current = inspectionProfile()[inspection.id]?.severity; + return [ + { + id: "inspection.disable", + label: `Disable Inspection '${inspection.name}'`, + verbatim: true, + run: () => report(disableInspection(inspection.id)), + }, + { + id: "inspection.severity", + label: "Change Severity", + submenu: [ + { + id: "severity.default", + label: "Default", + checked: current === undefined, + run: () => report(setInspectionSeverity(inspection.id, undefined)), + }, + ...INSPECTION_LEVELS.map((l) => ({ + id: `severity.${l.value}`, + label: l.label, + checked: current === l.value, + run: () => report(setInspectionSeverity(inspection.id, l.value)), + })), + ], + }, + { + id: "inspection.edit", + label: "Edit Inspection Profile Setting…", + icon: "settings", + run: () => editInspectionSettings(inspection.id), + }, + ]; +} diff --git a/src/platform/inspections.ts b/src/platform/inspections.ts index aed55b4f4..88fcfb512 100644 --- a/src/platform/inspections.ts +++ b/src/platform/inspections.ts @@ -19,6 +19,8 @@ export type ProfileLevel = "ide" | "project"; export interface InspectionOverride { enabled?: boolean; severity?: InspectionLevel; + /** The inspection's own options (`BuiltinInspection.options`). */ + options?: Readonly>; } export type ProfileOverrides = Readonly>; @@ -73,7 +75,9 @@ function validateOverrides(raw: unknown): Record { const entry: InspectionOverride = {}; if (typeof o.enabled === "boolean") entry.enabled = o.enabled; if (typeof o.severity === "string" && LEVELS.has(o.severity)) entry.severity = o.severity as InspectionLevel; - if (id && (entry.enabled !== undefined || entry.severity !== undefined)) out[id] = entry; + const options = Object.entries(obj(o.options)).filter(([, v]) => typeof v === "boolean"); + if (options.length) entry.options = Object.fromEntries(options) as Record; + if (id && Object.keys(entry).length) out[id] = entry; } return out; } @@ -195,6 +199,13 @@ export function newProfileKey(name: string, existing: CustomProfiles): string { // --- inspection identity ----------------------------------------------------- +/** A checkbox in the inspection's Options (Settings › Editor › Inspections). */ +export interface InspectionOptionSpec { + key: string; + label: string; + default: boolean; +} + export interface BuiltinInspection { id: string; /** Producer key the diagnostics arrive under. */ @@ -204,12 +215,20 @@ export interface BuiltinInspection { description: string; /** Highlighting level when the profile sets none (else as reported). */ defaultLevel?: InspectionLevel; + options?: readonly InspectionOptionSpec[]; } /** Producer key of the Typo inspection's diagnostics. */ export const SPELLING_PRODUCER = "spelling"; export const SPELLING_INSPECTION = "builtin:spelling"; +/** IDEA's Typo options: which parts of source files are checked (plain text always is). */ +export const SPELLING_OPTIONS = { + code: "processCode", + literals: "processLiterals", + comments: "processComments", +} as const; + /** Checks the IDE runs itself (not a language server). */ export const BUILTIN_INSPECTIONS: readonly BuiltinInspection[] = [ { @@ -241,12 +260,39 @@ export const BUILTIN_INSPECTIONS: readonly BuiltinInspection[] = [ name: "Typo", group: "Proofreading", description: - "Reports misspelt words in comments, string literals and the names declarations introduce (camelCase and snake_case parts checked separately), and in plain text and Markdown. Words can be saved to the project or application dictionary (Editor › Natural Languages › Spelling).", + "Reports misspelt words in comments, string literals and the names declarations introduce (camelCase and snake_case parts checked separately), and in plain text and Markdown. Agent nouns of known words (checkpointer) and, in names, two known words run together (filepath) are accepted. Words can be saved to the project or application dictionary (Editor › Natural Languages › Spelling). Correcting a name renames the symbol and its references.", defaultLevel: "typo", + options: [ + { key: SPELLING_OPTIONS.code, label: "Process code", default: true }, + { key: SPELLING_OPTIONS.literals, label: "Process literals", default: true }, + { key: SPELLING_OPTIONS.comments, label: "Process comments", default: true }, + ], }, ]; const BUILTIN_BY_PRODUCER = new Map(BUILTIN_INSPECTIONS.map((b) => [b.producer, b])); +const BUILTIN_BY_ID = new Map(BUILTIN_INSPECTIONS.map((b) => [b.id, b])); + +/** Option `key` of inspection `id` under `profile` (its default when unset). */ +export function inspectionOption(profile: ProfileOverrides, id: string, key: string): boolean { + const value = profile[id]?.options?.[key]; + if (typeof value === "boolean") return value; + return BUILTIN_BY_ID.get(id)?.options?.find((o) => o.key === key)?.default ?? true; +} + +/** The inspection a diagnostic of `producer` belongs to: its code's own when it has one. */ +export interface InspectionRef { + id: string; + name: string; +} + +export function diagnosticInspection(producer: string, d: Pick): InspectionRef | null { + if (!producer) return null; + const builtin = BUILTIN_BY_PRODUCER.get(producer); + if (builtin) return { id: builtin.id, name: builtin.name }; + const base = producerInspectionId(producer); + return d.code ? { id: codeInspectionId(base, d.code), name: `${producer} ${d.code}` } : { id: base, name: producer }; +} /** Inspection id of everything a producer reports. */ export function producerInspectionId(producer: string): string { diff --git a/src/platform/localDiagnostics.ts b/src/platform/localDiagnostics.ts new file mode 100644 index 000000000..6c6930419 --- /dev/null +++ b/src/platform/localDiagnostics.ts @@ -0,0 +1,19 @@ +/** + * Diagnostics computed in the UI (Typo, file references, SQL inspections), as + * full per-(file, producer) replacements like the backend's `diagnostics` + * event, so the Problems view sees them as well as the editor. + */ +import type { Diagnostic } from "@/bindings"; + +type Listener = (path: string, producer: string, items: readonly Diagnostic[]) => void; + +const listeners = new Set(); + +export function publishLocalDiagnostics(path: string, producer: string, items: readonly Diagnostic[]) { + listeners.forEach((l) => l(path, producer, items)); +} + +export function onLocalDiagnostics(listener: Listener): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +}