mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-14 16:07:52 +00:00
fix(plugins): harden the official plugins against crafted input (#3219)
Several official plugins trusted crafted input: paths outside the project were accepted where only project files were meant, some loops and replacements had no upper bound, and CSV Add Row wrapped its row index on wasm32. - ai-completion: only treat a real OpenAI host as OpenAI - checkstyle, license-header, mybatis: keep resolved paths under the project root - license-header: keep the generated header on one line where one is expected - csv-editor: Add Row appends on wasm32 instead of overwriting the header - regex-tester: cap the replaced output - solidity: linearisation stops on cyclic inheritance - mybatis: bound the mappers and statements it indexes - string-tools, graphviz: guard the remaining crafted-input cases - Rebuilt the changed components from source. Co-authored-by: rohitdanda <10144914+rohitdanda@users.noreply.github.com>
This commit is contained in:
co-authored by
rohitdanda
parent
01a60ef164
commit
d2c565b19d
@@ -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();
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
446156fc345df7c8252592f73129f3996139e5980c6ed9a296a9cbecc334eb17
|
||||
ec93db9b37437986167b4040714f5cda5cb7a2fc138e6c2dc2a9447faaf5a802
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
b072d07c4aa4a972a71fffba355ea215da7860b8f5527c6074055f52c52a29be
|
||||
1bd90bca250bb06ab3b0f27b045110c722685d894ce79d23648cb7c795d2c033
|
||||
|
||||
@@ -106,10 +106,16 @@ fn project_of(path: &str) -> Option<Project> {
|
||||
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 [
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
a78162e7c4c6ebd3a22c06737fd35948e11b0dd8949571617bc099ba6b4423f0
|
||||
c4c86cef9512ac03193e7d89d39b6ee41665c2d4721a78f9415d406289e9ece1
|
||||
|
||||
@@ -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<u32>| 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<u32> = rows.clone();
|
||||
rows.sort_unstable();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(/<g id="node\d+"/g)).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,3 +66,13 @@ export function exportName(file: string, extension: string): string {
|
||||
const base = file.replace(/\.(dot|gv)$/i, "") || "graph";
|
||||
return `${base}.${extension}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Graphviz's SVG without its links, for the preview: Graphviz writes a graph's
|
||||
* `URL` / `href` attributes as given (`javascript:` too), and the preview's
|
||||
* page may run inline script. Exports keep them. Graphviz quotes attribute
|
||||
* values with `"` and escapes the ones inside, so a value never holds one.
|
||||
*/
|
||||
export function withoutLinks(svg: string): string {
|
||||
return svg.replace(/\s(?:xlink:)?(?:href|target)="[^"]*"/g, "");
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
6d84abcc58c1894203506fa89855a6eade61f14add0ed72fdd7af9e3cd1298f6
|
||||
0d7731de73514b950d8579762029d98c46136f55bfdb2fa44e256cc9a9955a0f
|
||||
|
||||
@@ -58,8 +58,16 @@ pub fn has_header(text: &str) -> 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<String> {
|
||||
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<Project> {
|
||||
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);
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
a303522c605ccc9804e67f85326778a380951ce14022a70bc37ba3ae63b76f79
|
||||
959cb2ab750211bd1cc894a4d804acb2dd16e95c1e65b267e7eccbb64cbdd8da
|
||||
|
||||
@@ -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<Position> {
|
||||
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<Named> {
|
||||
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<Named> {
|
||||
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<Declaration> {
|
||||
}
|
||||
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<Project> {
|
||||
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<String, BTreeMap<String, (Instant, Option<String>)>>,
|
||||
/// Other mappers looked up for the file being linked (see [`MAX_OTHER_MAPPERS`]).
|
||||
other_mappers: BTreeSet<String>,
|
||||
}
|
||||
|
||||
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<String> = 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("<include")
|
||||
{
|
||||
let typed = &line[i + 7..];
|
||||
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 xml
|
||||
.sql_fragments
|
||||
.iter()
|
||||
@@ -949,12 +1028,12 @@ impl Providers for Navigator {
|
||||
&& !before[i..].contains('}')
|
||||
{
|
||||
let typed = &before[i + 2..];
|
||||
let statement =
|
||||
xml.statements.iter().rev().find(|s| offset(&doc.text, s.range.start).is_some_and(|o| o < cursor));
|
||||
let here = position(&doc.text, cursor);
|
||||
let statement = xml.statements.iter().rev().find(|s| s.range.start < here);
|
||||
let Some((_, params)) = statement.and_then(|s| java.methods.iter().find(|(m, _)| m.name == s.name)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
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 params
|
||||
.iter()
|
||||
.filter(|p| p.name.starts_with(typed))
|
||||
@@ -971,16 +1050,12 @@ impl Providers for Navigator {
|
||||
/// After `#{` / `${` inside an annotation SQL string: the method's parameters.
|
||||
fn annotation_param_completion(doc: &Document, at: Position) -> Vec<CompletionItem> {
|
||||
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<usize> = None;
|
||||
for t in ["#{", "${"] {
|
||||
@@ -993,7 +1068,7 @@ fn annotation_param_completion(doc: &Document, at: Position) -> Vec<CompletionIt
|
||||
if typed.contains('}') || !typed.chars().all(|c| ident(c) || c == '.') {
|
||||
continue;
|
||||
}
|
||||
let start = Position::new(at.line, at.character - typed.encode_utf16().count() as u32);
|
||||
let Some(start) = replace_start(at, typed) else { continue };
|
||||
return params
|
||||
.iter()
|
||||
.filter(|p| p.name.starts_with(typed))
|
||||
@@ -1477,4 +1552,89 @@ mod tests {
|
||||
let items = n.completion(&closed, Position::new(7, col as u32), None);
|
||||
assert_eq!(items.iter().map(|c| c.label.as_str()).collect::<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<usize> = (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::<Vec<_>>(), ["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("<mapper namespace=\"a.B\"\n<select id=\"x\"\n<sql id=\"y\"/>");
|
||||
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::<Vec<_>>(), ["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 = "<select id=\"a\" ".repeat(50_000);
|
||||
assert_eq!(parse_xml(&many).statements.len(), 50_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_before_an_unclosed_parameter_on_an_earlier_line() {
|
||||
let _host = setup();
|
||||
let text = "<mapper namespace=\"com.acme.UserMapper\">\n <select id=\"findById\">\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!(" <include refid=\"ns{n}.x\"/>\n")).collect();
|
||||
let text = format!("<mapper namespace=\"a.Self\">\n{refs}</mapper>\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);
|
||||
}
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
b7b4e6be26ff08cc0e7fa791cb956acd9afa09905720d2c0c93acdae5638a35e
|
||||
d5ec377869ded4ec0bf533d07d51610aec7a6813d43ead98a261b3f54e7d0655
|
||||
|
||||
@@ -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]
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
4f585c3a1a3ae01a89811e05d27fc39121dd8e74210c1281d04ff31556d393b5
|
||||
ebbd24e08c19953895464567cbaf1335b9847e98e8a8da8a4e0b8c2886c1abd6
|
||||
|
||||
@@ -347,6 +347,8 @@ fn highlights(text: &str) -> Vec<Token> {
|
||||
/// An open `{`: the symbol whose body it is, and what may be declared inside.
|
||||
struct Frame {
|
||||
symbol: Option<usize>,
|
||||
/// The innermost symbol of this frame or the ones around it.
|
||||
parent: Option<usize>,
|
||||
body: Body,
|
||||
}
|
||||
|
||||
@@ -366,7 +368,7 @@ fn outline(text: &str) -> Vec<Symbol> {
|
||||
let code: Vec<Lexeme> = 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<Symbol> = 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<Symbol> {
|
||||
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<Symbol>, 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<Symbol> {
|
||||
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<Symbol> {
|
||||
|
||||
fn folding(text: &str) -> Vec<Fold> {
|
||||
let lexemes = lex(text);
|
||||
let line_of = |offset: usize| text[..offset].bytes().filter(|&b| b == b'\n').count() as u32;
|
||||
let newlines: Vec<usize> = 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<Range<usize>> = 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::<Vec<_>>(),
|
||||
[("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));
|
||||
}
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
81637f9a265e6bcb9839b1019b0d58b43f80535fb1fca044716904aef0c12724
|
||||
997e2ac7520a561f22d12ad7013ed576262424ffa8acd69fab219f68d00c92f1
|
||||
|
||||
@@ -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; &"), "<a> 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]
|
||||
|
||||
Reference in New Issue
Block a user