diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index 2e09e8e76..374248b23 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -2992,13 +2992,18 @@ fn official_csv_editor_edits_the_shared_document() { f.host.view_event("openidea.csv-editor", "table", cell, inv.clone()).unwrap(); assert_eq!(f.workspace.text("people.csv"), "name,city\nAda,\"Paris, FR\"\nBob,Oslo\n"); + // Add Row appends, also on wasm32 (where its row index used to wrap to the header). + let add = ide_plugin::ViewEvent::Button { id: "add-row".into() }; + f.host.view_event("openidea.csv-editor", "table", add, inv.clone()).unwrap(); + assert_eq!(f.workspace.text("people.csv"), "name,city\nAda,\"Paris, FR\"\nBob,Oslo\n,\n"); + // Settings: without a header row every record is a row. let mut values = serde_json::Map::new(); values.insert("firstRowIsHeader".into(), serde_json::Value::Bool(false)); f.workspace.settings.lock().insert("openidea.csv-editor".into(), values); let view = f.host.render_view("openidea.csv-editor", "table", None, inv.clone()).unwrap(); let ide_plugin::ViewBody::Table(t) = &view.body else { panic!("{view:?}") }; - assert_eq!((t.columns[0].as_str(), t.total_rows), ("Column 1", 3)); + assert_eq!((t.columns[0].as_str(), t.total_rows), ("Column 1", 4)); // Undeclared views are refused before any code runs. let err = f.host.render_view("openidea.csv-editor", "other", None, inv).unwrap_err(); diff --git a/plugins/official/ai-completion/dist/plugin.wasm b/plugins/official/ai-completion/dist/plugin.wasm index e4d8002c2..b9267b428 100644 Binary files a/plugins/official/ai-completion/dist/plugin.wasm and b/plugins/official/ai-completion/dist/plugin.wasm differ diff --git a/plugins/official/ai-completion/dist/plugin.wasm.sources b/plugins/official/ai-completion/dist/plugin.wasm.sources index 84f010092..e40968491 100644 --- a/plugins/official/ai-completion/dist/plugin.wasm.sources +++ b/plugins/official/ai-completion/dist/plugin.wasm.sources @@ -1 +1 @@ -446156fc345df7c8252592f73129f3996139e5980c6ed9a296a9cbecc334eb17 +ec93db9b37437986167b4040714f5cda5cb7a2fc138e6c2dc2a9447faaf5a802 diff --git a/plugins/official/ai-completion/src/lib.rs b/plugins/official/ai-completion/src/lib.rs index c07d60f6c..2c9be5de2 100644 --- a/plugins/official/ai-completion/src/lib.rs +++ b/plugins/official/ai-completion/src/lib.rs @@ -31,10 +31,19 @@ struct Config { enabled: bool, } +/// Whether `endpoint` is OpenAI's API host itself, so the key may go to it: +/// `https`, exactly `api.openai.com` (or `:443`), and no `user@` before the host +/// (`https://api.openai.com@localhost` is localhost). +pub fn is_openai(endpoint: &str) -> bool { + let Some(rest) = endpoint.strip_prefix("https://") else { return false }; + let authority = rest.split(['/', '?', '#']).next().unwrap_or(""); + matches!(authority, "api.openai.com" | "api.openai.com:443") +} + fn config() -> Config { let get = |k: &str| storage::get(k).ok().flatten(); let endpoint = get("endpoint").unwrap_or_else(|| LOCAL.into()); - let openai = endpoint.starts_with(OPENAI); + let openai = is_openai(&endpoint); let model = get("model").unwrap_or_else(|| (if openai { OPENAI_MODEL } else { LOCAL_MODEL }).into()); Config { endpoint, model, openai, enabled: get("enabled").as_deref() != Some("false") } } @@ -195,6 +204,29 @@ mod tests { assert!(sent.headers.contains(&("Authorization".into(), "Bearer sk-test".into()))); } + #[test] + fn the_key_goes_to_openai_only() { + assert!(is_openai(OPENAI)); + assert!(is_openai("https://api.openai.com/")); + assert!(is_openai("https://api.openai.com:443/v1")); + for other in [ + "https://api.openai.com@localhost:9999", + "https://api.openai.com:x@localhost", + "https://api.openai.com.example.com", + "https://api.openai.com\\@localhost", + "http://api.openai.com", + "http://localhost:11434", + ] { + assert!(!is_openai(other), "{other}"); + } + + let host = MockHost::install().with_capabilities(&["storage"]); + AiCompletion::default() + .run_command("set-endpoint", &host.invocation(), Some("https://api.openai.com@localhost:9999")) + .unwrap(); + assert!(!config().openai, "no key for a look-alike endpoint"); + } + #[test] fn quiet_when_cancelled_denied_or_mid_word() { let host = MockHost::install().with_capabilities(&["storage"]); diff --git a/plugins/official/checkstyle/dist/plugin.wasm b/plugins/official/checkstyle/dist/plugin.wasm index cbf6d94cb..7974961ff 100644 Binary files a/plugins/official/checkstyle/dist/plugin.wasm and b/plugins/official/checkstyle/dist/plugin.wasm differ diff --git a/plugins/official/checkstyle/dist/plugin.wasm.sources b/plugins/official/checkstyle/dist/plugin.wasm.sources index d6fddb00e..848e9e9db 100644 --- a/plugins/official/checkstyle/dist/plugin.wasm.sources +++ b/plugins/official/checkstyle/dist/plugin.wasm.sources @@ -1 +1 @@ -b072d07c4aa4a972a71fffba355ea215da7860b8f5527c6074055f52c52a29be +1bd90bca250bb06ab3b0f27b045110c722685d894ce79d23648cb7c795d2c033 diff --git a/plugins/official/checkstyle/src/lib.rs b/plugins/official/checkstyle/src/lib.rs index 6159e7ce6..472d715df 100644 --- a/plugins/official/checkstyle/src/lib.rs +++ b/plugins/official/checkstyle/src/lib.rs @@ -106,10 +106,16 @@ fn project_of(path: &str) -> Option { host::projects() .ok()? .into_iter() - .filter(|p| p.root().is_ok_and(|r| path.starts_with(&r))) + .filter(|p| p.root().is_ok_and(|r| is_under(path, &r))) .max_by_key(|p| p.root().map(|r| r.len()).unwrap_or(0)) } +/// Whether `path` is `root` or inside it (`/proj-x/a` is not inside `/proj`). +fn is_under(path: &str, root: &str) -> bool { + path.strip_prefix(root) + .is_some_and(|rest| rest.is_empty() || root.ends_with(['/', '\\']) || rest.starts_with(['/', '\\'])) +} + fn windows_path(path: &str) -> String { let path = path.replace('\\', "/"); if let Some(rest) = path.strip_prefix("//?/UNC/") { @@ -311,6 +317,14 @@ mod tests { assert!(!report_matches_file("/project/A\\B.java", "/project/A/B.java")); } + #[test] + fn a_project_contains_only_its_own_paths() { + assert!(is_under("/proj/A.java", "/proj")); + assert!(is_under("C:\\proj\\A.java", "C:\\proj")); + assert!(is_under("/A.java", "/")); + assert!(!is_under("/proj-x/A.java", "/proj")); + } + #[test] fn windows_reports_match_the_saved_file_and_keep_quick_fixes() { for (root, reported) in [ diff --git a/plugins/official/csv-editor/dist/plugin.wasm b/plugins/official/csv-editor/dist/plugin.wasm index 9b64b7507..502e2161c 100644 Binary files a/plugins/official/csv-editor/dist/plugin.wasm and b/plugins/official/csv-editor/dist/plugin.wasm differ diff --git a/plugins/official/csv-editor/dist/plugin.wasm.sources b/plugins/official/csv-editor/dist/plugin.wasm.sources index fdc4912a6..617f2374c 100644 --- a/plugins/official/csv-editor/dist/plugin.wasm.sources +++ b/plugins/official/csv-editor/dist/plugin.wasm.sources @@ -1 +1 @@ -a78162e7c4c6ebd3a22c06737fd35948e11b0dd8949571617bc099ba6b4423f0 +c4c86cef9512ac03193e7d89d39b6ee41665c2d4721a78f9415d406289e9ece1 diff --git a/plugins/official/csv-editor/src/lib.rs b/plugins/official/csv-editor/src/lib.rs index 38cad0ee8..ba85883d2 100644 --- a/plugins/official/csv-editor/src/lib.rs +++ b/plugins/official/csv-editor/src/lib.rs @@ -143,8 +143,9 @@ impl CsvEditor { let d = csv.delimiter; let width = csv.records.iter().map(|r| r.fields.len()).max().unwrap_or(1).max(1); let empty_record = d.to_string().repeat(width - 1); - let record = |row: u32| csv.records.get(row as usize + skip); - let insert = |before: u32| match record(before) { + let record = |row: u32| csv.records.get(usize::try_from(row).ok()?.checked_add(skip)?); + // Before row `before`; at the end for `None` or a row past the end. + let insert = |before: Option| match before.and_then(record) { Some(r) => vec![edit(text, r.span.start..r.span.start, format!("{empty_record}\n"))], None => { // Append; the last record may lack its line break. @@ -166,8 +167,8 @@ impl CsvEditor { } } } - ViewEvent::InsertRow(before) => insert(*before), - ViewEvent::Button(b) if b == "add-row" => insert(u32::MAX), + ViewEvent::InsertRow(before) => insert(Some(*before)), + ViewEvent::Button(b) if b == "add-row" => insert(None), ViewEvent::DeleteRows(rows) => { let mut rows: Vec = rows.clone(); rows.sort_unstable(); diff --git a/plugins/official/graphviz/web-src/src/main.ts b/plugins/official/graphviz/web-src/src/main.ts index 04f8d5d8b..aa94d243d 100644 --- a/plugins/official/graphviz/web-src/src/main.ts +++ b/plugins/official/graphviz/web-src/src/main.ts @@ -6,7 +6,7 @@ * which turns it into the file's problems. */ import { instance, type Viz } from "@viz-js/viz"; -import { ENGINES, type Engine, exportName, problemsOf, renderOptions } from "./render"; +import { ENGINES, type Engine, exportName, problemsOf, renderOptions, withoutLinks } from "./render"; const RENDER_DELAY_MS = 150; const MIN_ZOOM = 0.1; @@ -73,7 +73,7 @@ function start(bridge: OpenideaBridge) { const problems = problemsOf(result); report(problems); if (result.status === "success") { - canvas.innerHTML = result.output; + canvas.innerHTML = withoutLinks(result.output); canvas.classList.remove("stale"); banner.hidden = true; if (fitted) fit(); diff --git a/plugins/official/graphviz/web-src/src/render.test.ts b/plugins/official/graphviz/web-src/src/render.test.ts index fe204b450..a845be901 100644 --- a/plugins/official/graphviz/web-src/src/render.test.ts +++ b/plugins/official/graphviz/web-src/src/render.test.ts @@ -1,6 +1,6 @@ import { instance } from "@viz-js/viz"; import { describe, expect, it } from "vitest"; -import { ENGINES, exportName, graphvizColor, problemsOf, renderOptions } from "./render"; +import { ENGINES, exportName, graphvizColor, problemsOf, renderOptions, withoutLinks } from "./render"; describe("the preview's rendering", () => { it("renders with the theme's colour as a default the graph can override", async () => { @@ -42,4 +42,22 @@ describe("the preview's rendering", () => { expect(exportName("Flow.GV", "png")).toBe("Flow.png"); expect(exportName("", "svg")).toBe("graph.svg"); }); + + it("previews a graph's links without them", async () => { + const viz = await instance(); + const js = "javascript:alert(1)"; + const graph = `digraph { + URL="${js}"; target="_top" + a [URL="${js}" href="https://example.com" label="a href=\\"x\\""] + a -> b [URL="${js}" edgeURL="${js}" headURL="${js}" tailURL="${js}" labelURL="${js}" label="e" headlabel="h" taillabel="t"] + subgraph cluster_x { URL="${js}"; c } + }`; + const result = viz.render(graph, renderOptions("dot", null)); + const svg = result.status === "success" ? result.output : ""; + expect(svg).toContain(js); + const preview = withoutLinks(svg); + expect(preview).not.toMatch(/\s(xlink:)?(href|target)="|javascript:/); + expect(preview).toContain("a href="x""); + expect(preview.match(/ bool { }) } +/// `value` on one line: a line break (or any control character, or the +/// Unicode line separators JavaScript ends a comment at) would put the rest of +/// the setting into the file as code. +fn one_line(value: &str) -> String { + value.chars().map(|c| if c.is_control() || matches!(c, '\u{2028}' | '\u{2029}') { ' ' } else { c }).collect() +} + /// The header's lines, without comment markers. pub fn header_lines(year: i64, owner: &str, license: &str) -> Vec { + let (owner, license) = (one_line(owner), one_line(license)); let owner = owner.trim(); let mut lines = vec![if owner.is_empty() { format!("Copyright (c) {year}") } else { format!("Copyright (c) {year} {owner}") }]; @@ -82,7 +90,8 @@ pub fn render(comment: Comment, lines: &[String]) -> String { out.push_str(open); out.push('\n'); for l in lines { - out.push_str(&format!(" * {l}\n")); + // `*/` would end the comment early. + out.push_str(&format!(" * {}\n", l.replace("*/", "* /"))); } out.push_str(close); out.push('\n'); @@ -170,10 +179,16 @@ fn project_of(path: &str) -> Option { host::projects() .ok()? .into_iter() - .filter(|p| p.root().is_ok_and(|r| path.starts_with(&r))) + .filter(|p| p.root().is_ok_and(|r| is_under(path, &r))) .max_by_key(|p| p.root().map(|r| r.len()).unwrap_or(0)) } +/// Whether `path` is `root` or inside it (`/proj-x/a` is not inside `/proj`). +fn is_under(path: &str, root: &str) -> bool { + path.strip_prefix(root) + .is_some_and(|rest| rest.is_empty() || root.ends_with(['/', '\\']) || rest.starts_with(['/', '\\'])) +} + #[derive(Default)] pub struct LicenseHeader; @@ -305,6 +320,15 @@ mod tests { assert_eq!(header_lines(2026, " ", ""), ["Copyright (c) 2026"]); } + #[test] + fn settings_cannot_add_code_to_the_file() { + let lines = header_lines(2026, "ACME\nimport os\r\u{2028}x", "MIT\nrm -rf"); + assert_eq!(lines, ["Copyright (c) 2026 ACME import os x", "SPDX-License-Identifier: MIT rm -rf"]); + assert_eq!(render(comment_for("a.py").unwrap(), &lines).lines().filter(|l| !l.starts_with('#')).count(), 1); + let css = render(comment_for("s.css").unwrap(), &header_lines(2026, "X */ body { }", "")); + assert_eq!(css.matches("*/").count(), 1, "{css}"); + } + #[test] fn keeps_shebang_and_encoding_lines_first() { assert_eq!(insert_line("fn main() {}\n"), 0); @@ -324,6 +348,12 @@ mod tests { assert!(header_edit("a.rs", "// Copyright (c) 2020 X\n", 2026, "", "").is_none()); } + #[test] + fn a_project_contains_only_its_own_paths() { + assert!(is_under("/proj/a.rs", "/proj")); + assert!(!is_under("/proj-x/a.rs", "/proj")); + } + #[test] fn years() { assert_eq!(year_of_day(0), 1970); diff --git a/plugins/official/mybatis/dist/plugin.wasm b/plugins/official/mybatis/dist/plugin.wasm index e87d8b2e7..b074cd549 100644 Binary files a/plugins/official/mybatis/dist/plugin.wasm and b/plugins/official/mybatis/dist/plugin.wasm differ diff --git a/plugins/official/mybatis/dist/plugin.wasm.sources b/plugins/official/mybatis/dist/plugin.wasm.sources index 3e02aa38c..d39431783 100644 --- a/plugins/official/mybatis/dist/plugin.wasm.sources +++ b/plugins/official/mybatis/dist/plugin.wasm.sources @@ -1 +1 @@ -a303522c605ccc9804e67f85326778a380951ce14022a70bc37ba3ae63b76f79 +959cb2ab750211bd1cc894a4d804acb2dd16e95c1e65b267e7eccbb64cbdd8da diff --git a/plugins/official/mybatis/src/lib.rs b/plugins/official/mybatis/src/lib.rs index a3a02079e..caf83ccf4 100644 --- a/plugins/official/mybatis/src/lib.rs +++ b/plugins/official/mybatis/src/lib.rs @@ -9,11 +9,15 @@ use openidea_plugin::text::{offset, position}; use openidea_plugin::{ Error, Invocation, Location, Plugin, Position, Project, Range, TextQuery, host, register_plugin, }; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::time::{Duration, Instant}; /// How long a namespace → XML file lookup is trusted. const INDEX_TTL: Duration = Duration::from_secs(30); +/// Lookups kept per project before the expired ones are dropped. +const MAX_INDEXED: usize = 1024; +/// Other mappers one file's references are looked up in (each is a search). +const MAX_OTHER_MAPPERS: usize = 32; /// Files declaring one namespace that are tried (normally one). const MAX_MAPPERS: u32 = 64; const STATEMENTS: [&str; 4] = ["select", "insert", "update", "delete"]; @@ -85,27 +89,77 @@ pub struct MapperJava { pub result_ids: Vec<(usize, Named)>, } -fn range_of(text: &str, start: usize, end: usize) -> Range { - Range::new(position(text, start), position(text, end)) +/// Positions of many byte offsets in one text, asked in about increasing +/// order: each one scans only from the offset asked before it, so a file with +/// many names costs about one pass, not one pass per name. +struct Positions<'a> { + text: &'a str, + at: usize, + pos: Position, +} + +fn utf16_len(s: &str) -> u32 { + s.chars().map(|c| c.len_utf16() as u32).sum() +} + +impl<'a> Positions<'a> { + fn new(text: &'a str) -> Self { + Positions { text, at: 0, pos: Position::new(0, 0) } + } + + fn of(&mut self, at: usize) -> Position { + let at = at.min(self.text.len()); + if at >= self.at { + for c in self.text[self.at..at].chars() { + if c == '\n' { + self.pos = Position::new(self.pos.line + 1, 0); + } else { + self.pos.character += c.len_utf16() as u32; + } + } + } else { + let back = &self.text[at..self.at]; + match back.matches('\n').count() as u32 { + 0 => self.pos.character -= utf16_len(back), + lines => { + let line_start = self.text[..at].rfind('\n').map_or(0, |i| i + 1); + self.pos = Position::new(self.pos.line - lines, utf16_len(&self.text[line_start..at])); + } + } + } + self.at = at; + self.pos + } + + fn range(&mut self, start: usize, end: usize) -> Range { + Range::new(self.of(start), self.of(end)) + } +} + +/// Where a completion that replaces `typed` (just before `at`, on its line) starts. +fn replace_start(at: Position, typed: &str) -> Option { + if typed.contains('\n') { + return None; + } + Some(Position::new(at.line, at.character.checked_sub(utf16_len(typed))?)) } pub fn parse_xml(text: &str) -> MapperXml { let mut out = MapperXml::default(); - let attr = |tag_start: usize, name: &str| -> Option<(String, usize, usize)> { - let tag_end = text[tag_start..].find('>').map_or(text.len(), |i| tag_start + i); - let tag = &text[tag_start..tag_end]; - let i = tag.find(&format!("{name}=\""))? + name.len() + 2; - let j = tag[i..].find('"')? + i; - Some((tag[i..j].to_owned(), tag_start + i, tag_start + j)) - }; + let mut positions = Positions::new(text); let mut at = 0; while let Some(i) = text[at..].find('<') { let start = at + i + 1; at = start; let name: String = text[start..].chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect(); - let named = |attr_name: &str| -> Option { - let (v, s, e) = attr(start, attr_name)?; - Some(Named { name: v, range: range_of(text, s, e) }) + // `<` cannot appear inside an attribute value, so a tag without its + // `>` ends at the next `<`: no tag is searched past the next one. + let tag_end = text[start..].find(['<', '>']).map_or(text.len(), |i| start + i); + let tag = &text[start..tag_end]; + let mut named = |attr_name: &str| -> Option { + let i = tag.find(&format!("{attr_name}=\""))? + attr_name.len() + 2; + let j = tag[i..].find('"')? + i; + Some(Named { name: tag[i..j].to_owned(), range: positions.range(start + i, start + j) }) }; if name == "mapper" { out.namespace = named("namespace"); @@ -163,7 +217,8 @@ fn skip_string(bytes: &[u8], mut i: usize) -> usize { _ => i += 1, } } - i + // A `\` as the last byte steps past the end. + i.min(bytes.len()) } /// The index just past the `//…` or `/*…*/` comment opening at `bytes[i]`. @@ -227,7 +282,7 @@ fn scan_declaration(stmt: &str) -> Option { } b'(' => { let before = stmt[..i].trim_end(); - let start = before.rfind(|c: char| !ident(c)).map_or(0, |p| p + 1); + let start = before.char_indices().rev().find(|&(_, c)| !ident(c)).map_or(0, |(p, c)| p + c.len_utf8()); if start == before.len() { return None; } @@ -463,11 +518,8 @@ fn annotation_string_arg(args: &str, key: &str) -> Option<(String, usize, usize) depth = depth.saturating_sub(1); i += 1; } - _ if depth == 0 && ident(bytes[i] as char) => { - let mut j = i + 1; - while j < bytes.len() && ident(bytes[j] as char) { - j += 1; - } + _ if depth == 0 && args.is_char_boundary(i) && args[i..].chars().next().is_some_and(ident) => { + let j = args[i..].char_indices().find(|&(_, c)| !ident(c)).map_or(args.len(), |(n, _)| i + n); if &args[i..j] == key { let mut k = j; while k < bytes.len() && bytes[k].is_ascii_whitespace() { @@ -548,8 +600,9 @@ pub fn parse_java(text: &str) -> MapperJava { Some(p) => format!("{p}.{simple}"), None => simple.clone(), }); + let mut positions = Positions::new(text); let decl = - Some(Named { name: simple.clone(), range: range_of(text, i + 10 + indent, i + 10 + indent + simple.len()) }); + Some(Named { name: simple.clone(), range: positions.range(i + 10 + indent, i + 10 + indent + simple.len()) }); let body_start = text[i..].find('{').map_or(text.len(), |b| i + b + 1); let mut methods = Vec::new(); let mut annotation_sql = Vec::new(); @@ -599,22 +652,21 @@ pub fn parse_java(text: &str) -> MapperJava { { let (s, e) = (stmt_start + decl.name.0, stmt_start + decl.name.1); let list_base = stmt_start + decl.parens.0 + 1; + let method_name = + Named { name: stmt[decl.name.0..decl.name.1].to_owned(), range: positions.range(s, e) }; let params = params_of(&stmt[decl.parens.0 + 1..decl.parens.1]) .into_iter() - .map(|(name, ps, pe)| Param { name, range: range_of(text, list_base + ps, list_base + pe) }) + .map(|(name, ps, pe)| Param { name, range: positions.range(list_base + ps, list_base + pe) }) .collect(); let method = methods.len(); - methods.push(( - Named { name: stmt[decl.name.0..decl.name.1].to_owned(), range: range_of(text, s, e) }, - params, - )); + methods.push((method_name, params)); let mut strings = Vec::new(); let mut sql_params = Vec::new(); for (ss, se, ps) in annotation_sql_of(&stmt[..decl.parens.0]) { - strings.push(range_of(text, stmt_start + ss, stmt_start + se)); + strings.push(positions.range(stmt_start + ss, stmt_start + se)); sql_params.extend(ps.into_iter().map(|(n, ms, me)| Named { name: n, - range: range_of(text, stmt_start + ms, stmt_start + me), + range: positions.range(stmt_start + ms, stmt_start + me), })); } if !strings.is_empty() { @@ -623,10 +675,10 @@ pub fn parse_java(text: &str) -> MapperJava { let (refs, defs) = result_map_of(&stmt[..decl.parens.0]); result_refs.extend(refs.into_iter().map(|(v, rs, re)| ResultMapRef { method, - name: Named { name: v, range: range_of(text, stmt_start + rs, stmt_start + re) }, + name: Named { name: v, range: positions.range(stmt_start + rs, stmt_start + re) }, })); result_ids.extend(defs.into_iter().map(|(v, ds, de)| { - (method, Named { name: v, range: range_of(text, stmt_start + ds, stmt_start + de) }) + (method, Named { name: v, range: positions.range(stmt_start + ds, stmt_start + de) }) })); } stmt_start = k + 1; @@ -641,8 +693,13 @@ pub fn parse_java(text: &str) -> MapperJava { /// `namespace="com.acme.UserMapper"` in an XML file, either quote. fn namespace_query(namespace: &str) -> TextQuery { - let escaped: String = - namespace.chars().flat_map(|c| matches!(c, '.' | '$').then_some('\\').into_iter().chain([c])).collect(); + let mut escaped = String::with_capacity(namespace.len()); + for c in namespace.chars() { + if r"\.+*?()|[]{}^$#&-~".contains(c) { + escaped.push('\\'); + } + escaped.push(c); + } TextQuery { pattern: format!(r#"namespace\s*=\s*["']{escaped}["']"#), regex: true, @@ -656,14 +713,22 @@ fn project_of(path: &str) -> Option { host::projects() .ok()? .into_iter() - .filter(|p| p.root().is_ok_and(|r| path.starts_with(&r))) + .filter(|p| p.root().is_ok_and(|r| is_under(path, &r))) .max_by_key(|p| p.root().map(|r| r.len()).unwrap_or(0)) } +/// Whether `path` is `root` or inside it (`/proj-x/a` is not inside `/proj`). +fn is_under(path: &str, root: &str) -> bool { + path.strip_prefix(root) + .is_some_and(|rest| rest.is_empty() || root.ends_with(['/', '\\']) || rest.starts_with(['/', '\\'])) +} + #[derive(Default)] pub struct Navigator { /// project root → namespace → (when it was looked up, the XML mapping it, if any). xml_index: BTreeMap)>>, + /// Other mappers looked up for the file being linked (see [`MAX_OTHER_MAPPERS`]). + other_mappers: BTreeSet, } impl Navigator { @@ -687,15 +752,20 @@ impl Navigator { let mut paths: Vec<&str> = found.matches.iter().map(|m| m.location.path.as_str()).collect(); paths.dedup(); let hit = paths.into_iter().find_map(|path| Some((path.to_owned(), read(path)?))); - self.xml_index - .entry(root) - .or_default() - .insert(namespace.to_owned(), (Instant::now(), hit.as_ref().map(|(path, _)| path.clone()))); + let index = self.xml_index.entry(root).or_default(); + if index.len() >= MAX_INDEXED { + index.retain(|_, (at, _)| at.elapsed() < INDEX_TTL); + } + index.insert(namespace.to_owned(), (Instant::now(), hit.as_ref().map(|(path, _)| path.clone()))); hit } /// The interface `namespace` names (`com.acme.UserMapper` → `**/com/acme/UserMapper.java`). fn java_for(project: &Project, namespace: &str) -> Option<(String, MapperJava)> { + // A Java name only: anything else would be read as a glob. + if namespace.is_empty() || !namespace.chars().all(|c| ident(c) || c == '.') { + return None; + } let rel = format!("**/{}.java", namespace.replace('.', "/")); let path = project.find_files(&rel, 1).ok()?.into_iter().next()?; let java = parse_java(&project.read_file(&path).ok()?); @@ -722,6 +792,14 @@ impl Navigator { if ns == here_ns { return pick(xml, doc.path.clone()); } + // Each other mapper is a project search: a file naming thousands of them + // resolves the first few rather than running out of time. + if !self.other_mappers.contains(ns) { + if self.other_mappers.len() >= MAX_OTHER_MAPPERS { + return None; + } + self.other_mappers.insert(ns.to_owned()); + } let (path, other) = self.xml_for(project, ns)?; pick(&other, path) } @@ -751,7 +829,7 @@ impl Navigator { } typed }; - let start = Position::new(at.line, at.character - typed.encode_utf16().count() as u32); + let start = replace_start(at, typed)?; let mut ids: Vec = java.result_ids.iter().map(|(_, d)| d.name.clone()).collect(); if let Some(project) = project_of(&doc.path) && let Some(name) = &java.name @@ -796,6 +874,7 @@ impl Navigator { })); } // resultMap / sql references resolve even when the interface is missing. + self.other_mappers.clear(); for r in xml.result_map_refs.iter().chain(xml.extends_refs.iter()) { if let Some(target) = self.anchor_target(&project, doc, &xml, &ns.name, &r.name, false) { out.push((r.clone(), target, format!("resultMap {}", r.name))); @@ -914,7 +993,7 @@ impl Providers for Navigator { && STATEMENTS.iter().any(|t| line.contains(&format!("<{t}"))) { let typed = &line[i + 4..]; - let start = Position::new(at.line, at.character - typed.encode_utf16().count() as u32); + let Some(start) = replace_start(at, typed) else { return Vec::new() }; return java .methods .iter() @@ -932,7 +1011,7 @@ impl Providers for Navigator { && line.contains(" Vec { let Some(cursor) = offset(&doc.text, at) else { return Vec::new() }; + let here = position(&doc.text, cursor); let java = parse_java(&doc.text); for sql in &java.annotation_sql { let (_, params) = &java.methods[sql.method]; - for s in &sql.strings { - let (Some(start), Some(end)) = (offset(&doc.text, s.start), offset(&doc.text, s.end)) else { - continue; - }; - if !(start <= cursor && cursor <= end) { - continue; - } + for s in sql.strings.iter().filter(|s| s.start <= here && here <= s.end) { + let Some(start) = offset(&doc.text, s.start) else { continue }; let seg = &doc.text[start..cursor]; let mut token: Option = None; for t in ["#{", "${"] { @@ -993,7 +1068,7 @@ fn annotation_param_completion(doc: &Document, at: Position) -> Vec>(), ["userMap"]); } + + #[test] + fn positions_agree_with_the_sdk_in_any_order() { + let text = "a\n中文 😀 x\r\n\n é\nlast"; + let mut positions = Positions::new(text); + let boundaries: Vec = (0..=text.len()).filter(|&i| text.is_char_boundary(i)).collect(); + let order = boundaries.iter().chain(boundaries.iter().rev()).chain(boundaries.iter().step_by(3).rev()); + for &at in order.chain([0, text.len(), 5, 1, text.len()].iter()) { + assert_eq!(positions.of(at), position(text, at), "at {at}"); + } + } + + /// Text a mapper may hold (or be crafted to hold) must not crash the plugin. + #[test] + fn odd_java_does_not_panic() { + // A no-break space before the name: the char before it is 2 bytes. + let java = parse_java("package a;\ninterface M {\n User\u{00A0}findById(long id);\n}\n"); + assert_eq!(java.methods.iter().map(|(m, _)| m.name.as_str()).collect::>(), ["findById"]); + // A non-ASCII identifier among `@Results` arguments. + let java = parse_java("interface M { @Results(é) User f(); @Results(id = 常量.ID, x = \"y\") User g(); }"); + assert_eq!(java.methods.len(), 2); + // A string the annotation scanner reads as a char literal, ending in `\`. + for text in [ + "package a;\ninterface M {\n @Select('\"' \\) void f();\n}", + "interface M { @Select(/*\"*/ \\) void f(); }", + ] { + parse_java(text); + } + assert_eq!(skip_string(b"\"a\\", 0), 3); + } + + #[test] + fn a_tag_without_its_end_stops_at_the_next() { + let xml = parse_xml(""); + assert_eq!(xml.namespace.map(|n| n.name).as_deref(), Some("a.B")); + assert_eq!(xml.statements.iter().map(|s| s.name.as_str()).collect::>(), ["x"]); + assert_eq!(xml.sql_fragments[0].range, Range::new(Position::new(2, 9), Position::new(2, 10))); + // Many unclosed tags parse in one pass, not one pass per tag. + let many = "\n where id = #{i\nx"; + let xml = doc("/project/src/main/resources/mapper/UserMapper.xml", "xml", text); + assert!(Navigator::default().completion(&xml, Position::new(3, 0), None).is_empty()); + } + + #[test] + fn namespaces_are_searched_and_globbed_literally() { + let pattern = namespace_query("a|b.x[0]").pattern; + assert!(pattern.contains(r"a\|b\.x\[0\]"), "{pattern}"); + let _host = setup(); + let project = host::projects().unwrap().remove(0); + assert!(Navigator::java_for(&project, "com.acme.UserMapper").is_some()); + assert!(Navigator::java_for(&project, "com.*.UserMapper").is_none()); + assert!(Navigator::java_for(&project, "").is_none()); + } + + #[test] + fn a_project_contains_only_its_own_paths() { + assert!(is_under("/proj/a.xml", "/proj")); + assert!(is_under("/proj/a.xml", "/proj/")); + assert!(is_under("C:\\proj\\a.xml", "C:\\proj")); + assert!(is_under("/proj", "/proj")); + assert!(!is_under("/proj-x/a.xml", "/proj")); + } + + #[test] + fn a_file_naming_many_mappers_looks_up_a_few() { + let host = MockHost::install(); + let searches = std::rc::Rc::new(std::cell::Cell::new(0)); + let count = searches.clone(); + host.on_search(move |_, _| { + count.set(count.get() + 1); + Ok(TextSearch { matches: Vec::new(), limit_hit: false }) + }); + let refs: String = (0..200).map(|n| format!(" \n")).collect(); + let text = format!("\n{refs}\n"); + let xml = doc("/project/src/m.xml", "xml", &text); + assert!(Navigator::default().line_markers(&xml).is_empty()); + assert_eq!(searches.get(), MAX_OTHER_MAPPERS); + } } diff --git a/plugins/official/regex-tester/dist/plugin.wasm b/plugins/official/regex-tester/dist/plugin.wasm index 0a5bc9555..26c8ea290 100644 Binary files a/plugins/official/regex-tester/dist/plugin.wasm and b/plugins/official/regex-tester/dist/plugin.wasm differ diff --git a/plugins/official/regex-tester/dist/plugin.wasm.sources b/plugins/official/regex-tester/dist/plugin.wasm.sources index fd2ea7121..6dddf90e1 100644 --- a/plugins/official/regex-tester/dist/plugin.wasm.sources +++ b/plugins/official/regex-tester/dist/plugin.wasm.sources @@ -1 +1 @@ -b7b4e6be26ff08cc0e7fa791cb956acd9afa09905720d2c0c93acdae5638a35e +d5ec377869ded4ec0bf533d07d51610aec7a6813d43ead98a261b3f54e7d0655 diff --git a/plugins/official/regex-tester/src/lib.rs b/plugins/official/regex-tester/src/lib.rs index f01f23838..1c455d6b6 100644 --- a/plugins/official/regex-tester/src/lib.rs +++ b/plugins/official/regex-tester/src/lib.rs @@ -94,7 +94,23 @@ pub fn evaluate(run: &Run) -> Value { } } let replaced = run.replace.as_ref().map(|r| { - let mut out = re.replace_all(&run.text, r.as_str()).into_owned(); + // Built match by match and cut off at the limit: `replace_all` would + // first build all of it (an empty match at every character, each + // replaced with a long text, runs out of memory). + let mut out = String::new(); + let mut last = 0; + for caps in re.captures_iter(&run.text) { + let m = caps.get(0).expect("group 0"); + out.push_str(&run.text[last..m.start()]); + caps.expand(r, &mut out); + last = m.end(); + if out.len() > MAX_REPLACED { + break; + } + } + if out.len() <= MAX_REPLACED { + out.push_str(&run.text[last..]); + } if out.len() > MAX_REPLACED { let mut cut = MAX_REPLACED; while !out.is_char_boundary(cut) { @@ -241,6 +257,13 @@ mod tests { assert!(bad["error"].as_str().unwrap().contains("unclosed"), "{bad}"); assert_eq!(bad["count"], 0); assert_eq!(run("", "", "x", None)["count"], 0); + let long = run("(?:)", "", &"a".repeat(100_000), Some(&"b".repeat(10_000))); + let replaced = long["replaced"].as_str().unwrap(); + assert!(replaced.len() <= MAX_REPLACED + '…'.len_utf8() && replaced.ends_with('…')); + for (pattern, text, with) in [("a*", "baac", "[$0]"), (r"(\w)(\d)", "a1 b2 c", "$2$1"), ("x", "y", "z")] { + let expected = regex::Regex::new(pattern).unwrap().replace_all(text, with).into_owned(); + assert_eq!(run(pattern, "", text, Some(with))["replaced"], expected); + } } #[test] diff --git a/plugins/official/solidity/dist/plugin.wasm b/plugins/official/solidity/dist/plugin.wasm index 11170d760..b40bf3627 100644 Binary files a/plugins/official/solidity/dist/plugin.wasm and b/plugins/official/solidity/dist/plugin.wasm differ diff --git a/plugins/official/solidity/dist/plugin.wasm.sources b/plugins/official/solidity/dist/plugin.wasm.sources index b18867464..9bb895e65 100644 --- a/plugins/official/solidity/dist/plugin.wasm.sources +++ b/plugins/official/solidity/dist/plugin.wasm.sources @@ -1 +1 @@ -4f585c3a1a3ae01a89811e05d27fc39121dd8e74210c1281d04ff31556d393b5 +ebbd24e08c19953895464567cbaf1335b9847e98e8a8da8a4e0b8c2886c1abd6 diff --git a/plugins/official/solidity/src/lib.rs b/plugins/official/solidity/src/lib.rs index def94beeb..3c6a1a9a6 100644 --- a/plugins/official/solidity/src/lib.rs +++ b/plugins/official/solidity/src/lib.rs @@ -347,6 +347,8 @@ fn highlights(text: &str) -> Vec { /// An open `{`: the symbol whose body it is, and what may be declared inside. struct Frame { symbol: Option, + /// The innermost symbol of this frame or the ones around it. + parent: Option, body: Body, } @@ -366,7 +368,7 @@ fn outline(text: &str) -> Vec { let code: Vec = lex(text).into_iter().filter(|l| !l.is_trivia()).collect(); let word = |i: usize| code.get(i).map_or("", |l| l.text(text)); let mut symbols: Vec = Vec::new(); - let mut frames = vec![Frame { symbol: None, body: Body::File }]; + let mut frames = vec![Frame { symbol: None, parent: None, body: Body::File }]; // A declaration waiting for its body (`{`) or its end (`;`). let mut pending: Option<(usize, Body)> = None; let mut stmt_start = 0usize; @@ -375,7 +377,9 @@ fn outline(text: &str) -> Vec { let l = code[i]; let w = l.text(text); let body = frames.last().map_or(Body::File, |f| f.body); - let parent = frames.iter().rev().find_map(|f| f.symbol); + // From the top frame alone: walking every open `{` per token is + // quadratic in deeply nested code. + let parent = frames.last().and_then(|f| f.parent); let add = |symbols: &mut Vec, name_at: usize, kind: SymbolKind, detail: Option<&str>| { let name = code[name_at]; symbols.push(Symbol { @@ -395,7 +399,7 @@ fn outline(text: &str) -> Vec { Some((s, b)) => (Some(s), b), None => (None, if body == Body::Code || body == Body::Contract { Body::Code } else { body }), }; - frames.push(Frame { symbol, body: inner }); + frames.push(Frame { symbol, parent: symbol.or(parent), body: inner }); stmt_start = i + 1; } (Kind::Punct, "}") => { @@ -512,7 +516,8 @@ fn outline(text: &str) -> Vec { fn folding(text: &str) -> Vec { let lexemes = lex(text); - let line_of = |offset: usize| text[..offset].bytes().filter(|&b| b == b'\n').count() as u32; + let newlines: Vec = text.bytes().enumerate().filter(|&(_, b)| b == b'\n').map(|(i, _)| i).collect(); + let line_of = |offset: usize| newlines.partition_point(|&n| n < offset) as u32; let skip: Vec> = lexemes .iter() .filter(|l| matches!(l.kind, Kind::Str | Kind::Comment | Kind::DocComment)) @@ -686,4 +691,19 @@ function free(uint x) pure returns (uint) { return x; } let _ = folding(text); } } + + /// Deep nesting and many comments stay about linear (both used to be quadratic). + #[test] + fn large_inputs_stay_linear() { + let deep = format!("contract C {{ function f() public {{ {} }} }}", "{".repeat(200_000)); + let symbols = outline(&deep); + assert_eq!( + symbols.iter().map(|s| (s.name.as_str(), s.parent)).collect::>(), + [("C", None), ("f", Some(0))] + ); + let comments = "/* a\n */\n".repeat(100_000); + let folds = folding(&comments); + assert_eq!(folds.len(), 100_000); + assert_eq!((folds[99_999].start_line, folds[99_999].end_line), (199_998, 199_999)); + } } diff --git a/plugins/official/string-tools/dist/plugin.wasm b/plugins/official/string-tools/dist/plugin.wasm index 921774ac1..d8e46f152 100644 Binary files a/plugins/official/string-tools/dist/plugin.wasm and b/plugins/official/string-tools/dist/plugin.wasm differ diff --git a/plugins/official/string-tools/dist/plugin.wasm.sources b/plugins/official/string-tools/dist/plugin.wasm.sources index 2abe7ac08..2b6e7dc62 100644 --- a/plugins/official/string-tools/dist/plugin.wasm.sources +++ b/plugins/official/string-tools/dist/plugin.wasm.sources @@ -1 +1 @@ -81637f9a265e6bcb9839b1019b0d58b43f80535fb1fca044716904aef0c12724 +997e2ac7520a561f22d12ad7013ed576262424ffa8acd69fab219f68d00c92f1 diff --git a/plugins/official/string-tools/src/lib.rs b/plugins/official/string-tools/src/lib.rs index 805b78ebf..75820908c 100644 --- a/plugins/official/string-tools/src/lib.rs +++ b/plugins/official/string-tools/src/lib.rs @@ -230,7 +230,8 @@ fn step_numbers(s: &str, by: i64) -> String { out.pop(); // the minus sign } let width = digits.len(); - let text = if next < 0 { format!("-{:0width$}", -next) } else { format!("{next:0width$}") }; + let text = + if next < 0 { format!("-{:0width$}", next.unsigned_abs()) } else { format!("{next:0width$}") }; out.push_str(&text); } Err(_) => out.push_str(&digits), @@ -338,7 +339,8 @@ fn html_unescape(s: &str) -> String { while let Some(i) = rest.find('&') { out.push_str(&rest[..i]); rest = &rest[i..]; - let Some(end) = rest[..rest.len().min(12)].find(';') else { + // `;` is ASCII, so where it is found is a char boundary. + let Some(end) = rest.bytes().take(12).position(|b| b == b';') else { out.push('&'); rest = &rest[1..]; continue; @@ -416,6 +418,7 @@ mod tests { assert_eq!(base64_encode(b"Man"), "TWFu"); assert_eq!(url_encode("a b&c"), "a%20b%26c"); assert_eq!(html_unescape("<a> AB &bogus; &"), " AB &bogus; &"); + assert_eq!(html_unescape("R&D部门的预算 &aaaaaaaaaa€"), "R&D部门的预算 &aaaaaaaaaa€"); assert_eq!(json_escape("a\"b\n"), "a\\\"b\\n"); } @@ -426,6 +429,8 @@ mod tests { assert_eq!(t("unique-lines", "a\nb\na"), "a\nb"); assert_eq!(t("increment", "v1.09 and -1"), "v2.10 and 0"); assert_eq!(t("decrement", "x0"), "x-1"); + assert_eq!(t("decrement", "-9223372036854775807"), "-9223372036854775808"); + assert_eq!(t("decrement", "-9223372036854775808"), "-9223372036854775808"); } #[test]