дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
Signed-off-by: Inf1mum <[email protected]> Co-authored-by: arookieofc <[email protected]>
602 рядки
26 KiB
Rust
602 рядки
26 KiB
Rust
//! MyBatis Navigator for OpenIDEA: a mapper interface (`com.acme.UserMapper`)
|
|
//! and the XML with `<mapper namespace="com.acme.UserMapper">` point at each
|
|
//! other: gutter arrows and Go to Definition on methods and statement ids,
|
|
//! completion of statement ids and `#{parameters}`.
|
|
|
|
use openidea_plugin::providers::{CompletionItem, CompletionKind, Document, LineMarker, Providers};
|
|
use openidea_plugin::text::{offset, position};
|
|
use openidea_plugin::{Error, Invocation, Location, Plugin, Position, Project, Range, host, register_plugin};
|
|
use std::collections::BTreeMap;
|
|
use std::time::{Duration, Instant};
|
|
|
|
/// How long the namespace → XML file index is trusted.
|
|
const INDEX_TTL: Duration = Duration::from_secs(30);
|
|
const MAX_XML: u32 = 5000;
|
|
const STATEMENTS: [&str; 4] = ["select", "insert", "update", "delete"];
|
|
|
|
/// A named thing and where its name is.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct Named {
|
|
pub name: String,
|
|
pub range: Range,
|
|
}
|
|
|
|
/// A mapper XML file.
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
|
pub struct MapperXml {
|
|
pub namespace: Option<String>,
|
|
/// `<select|insert|update|delete id="…">` (the id value's range).
|
|
pub statements: Vec<Named>,
|
|
}
|
|
|
|
/// A Java interface.
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
|
pub struct MapperJava {
|
|
/// Fully qualified name.
|
|
pub name: Option<String>,
|
|
pub methods: Vec<(Named, Vec<String>)>,
|
|
}
|
|
|
|
fn range_of(text: &str, start: usize, end: usize) -> Range {
|
|
Range::new(position(text, start), position(text, end))
|
|
}
|
|
|
|
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 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();
|
|
if name == "mapper" {
|
|
out.namespace = attr(start, "namespace").map(|(v, ..)| v);
|
|
} else if STATEMENTS.contains(&name.as_str())
|
|
&& let Some((id, s, e)) = attr(start, "id")
|
|
{
|
|
out.statements.push(Named { name: id, range: range_of(text, s, e) });
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn ident(c: char) -> bool {
|
|
c.is_alphanumeric() || c == '_' || c == '$'
|
|
}
|
|
|
|
/// The index just past the string or char literal opening at `bytes[i]`.
|
|
fn skip_string(bytes: &[u8], mut i: usize) -> usize {
|
|
let quote = bytes[i];
|
|
i += 1;
|
|
while i < bytes.len() {
|
|
match bytes[i] {
|
|
b'\\' => i += 2,
|
|
b if b == quote => return i + 1,
|
|
_ => i += 1,
|
|
}
|
|
}
|
|
i
|
|
}
|
|
|
|
/// The index just past the `//…` or `/*…*/` comment opening at `bytes[i]`.
|
|
fn skip_comment(bytes: &[u8], mut i: usize) -> usize {
|
|
if bytes.get(i + 1) == Some(&b'/') {
|
|
bytes[i + 2..].iter().position(|&b| b == b'\n').map_or(bytes.len(), |n| i + 2 + n)
|
|
} else {
|
|
i += 2;
|
|
while i + 1 < bytes.len() && (bytes[i] != b'*' || bytes[i + 1] != b'/') {
|
|
i += 1;
|
|
}
|
|
(i + 2).min(bytes.len())
|
|
}
|
|
}
|
|
|
|
/// A method declaration in a fragment of an interface's body: the name's byte
|
|
/// range and its parameter list's `(` and `)` (offsets in the fragment).
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
struct Declaration {
|
|
name: (usize, usize),
|
|
parens: (usize, usize),
|
|
}
|
|
|
|
/// The method declaration in `stmt`, skipping comments, strings and annotations
|
|
/// on the way: an annotation's arguments nest parentheses and may hold strings
|
|
/// and comments, so the name is the identifier before the first `(` left over —
|
|
/// `@Select("…") User find(…)` names `find`, not `Select`. Anything past that
|
|
/// `(` (where `@Param(…)` lives) only matters for its matching `)`, so it never
|
|
/// confuses the scan.
|
|
fn scan_declaration(stmt: &str) -> Option<Declaration> {
|
|
let bytes = stmt.as_bytes();
|
|
let mut i = 0;
|
|
let mut nested = 0usize; // paren depth inside an annotation's arguments
|
|
while i < bytes.len() {
|
|
match bytes[i] {
|
|
b' ' | b'\t' | b'\r' | b'\n' => i += 1,
|
|
b'/' if matches!(bytes.get(i + 1), Some(b'/' | b'*')) => i = skip_comment(bytes, i),
|
|
b'"' | b'\'' => i = skip_string(bytes, i),
|
|
b'@' if nested == 0 => {
|
|
// The annotation's qualified name, then (optionally) its arguments.
|
|
i += 1;
|
|
while i < bytes.len() && (ident(bytes[i] as char) || bytes[i] == b'.') {
|
|
i += 1;
|
|
}
|
|
let mut j = i;
|
|
while j < bytes.len() && bytes[j].is_ascii_whitespace() {
|
|
j += 1;
|
|
}
|
|
if bytes.get(j) == Some(&b'(') {
|
|
nested = 1;
|
|
i = j + 1;
|
|
}
|
|
}
|
|
b'(' if nested > 0 => {
|
|
nested += 1;
|
|
i += 1;
|
|
}
|
|
b')' if nested > 0 => {
|
|
nested -= 1;
|
|
i += 1;
|
|
}
|
|
b'(' => {
|
|
let before = stmt[..i].trim_end();
|
|
let start = before.rfind(|c: char| !ident(c)).map_or(0, |p| p + 1);
|
|
if start == before.len() {
|
|
return None;
|
|
}
|
|
// The matching `)`, minding strings and nested parens.
|
|
let (mut close, mut depth) = (i + 1, 1usize);
|
|
while close < bytes.len() {
|
|
match bytes[close] {
|
|
b'"' | b'\'' => close = skip_string(bytes, close),
|
|
b'/' if matches!(bytes.get(close + 1), Some(b'/' | b'*')) => close = skip_comment(bytes, close),
|
|
b'(' => {
|
|
depth += 1;
|
|
close += 1;
|
|
}
|
|
b')' => {
|
|
depth -= 1;
|
|
if depth == 0 {
|
|
return Some(Declaration { name: (start, before.len()), parens: (i, close) });
|
|
}
|
|
close += 1;
|
|
}
|
|
_ => close += 1,
|
|
}
|
|
}
|
|
return None; // an unterminated parameter list is no declaration
|
|
}
|
|
_ => i += 1,
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// The parameter names in a declaration's parameter list: `@Param("…")` wins,
|
|
/// otherwise the parameter's last identifier. Split at commas outside nested
|
|
/// parens, braces, brackets and strings, so `@Size(min = 1, max = 5) String n`
|
|
/// stays one parameter and a `@Param("a,b")` keeps its whole name.
|
|
fn params_of(list: &str) -> Vec<String> {
|
|
let bytes = list.as_bytes();
|
|
let mut parts = Vec::new();
|
|
let (mut depth, mut start) = (0usize, 0usize);
|
|
let mut i = 0;
|
|
while i < bytes.len() {
|
|
match bytes[i] {
|
|
b'"' | b'\'' => i = skip_string(bytes, i),
|
|
b'/' if matches!(bytes.get(i + 1), Some(b'/' | b'*')) => i = skip_comment(bytes, i),
|
|
b'(' | b'{' | b'[' => {
|
|
depth += 1;
|
|
i += 1;
|
|
}
|
|
b')' | b'}' | b']' => {
|
|
depth = depth.saturating_sub(1);
|
|
i += 1;
|
|
}
|
|
b',' if depth == 0 => {
|
|
parts.push(&list[start..i]);
|
|
start = i + 1;
|
|
i += 1;
|
|
}
|
|
_ => i += 1,
|
|
}
|
|
}
|
|
parts.push(&list[start..]);
|
|
parts
|
|
.into_iter()
|
|
.filter_map(|p| {
|
|
// `@Param("id") long id` → the @Param name wins.
|
|
if let Some(q) = p.find("@Param(\"") {
|
|
return p[q + 8..].split('"').next().map(str::to_owned);
|
|
}
|
|
p.split_whitespace().last().map(|n| n.trim_matches(|c: char| !ident(c)).to_owned())
|
|
})
|
|
.filter(|n| !n.is_empty())
|
|
.collect()
|
|
}
|
|
|
|
/// The interface's name and methods (with their parameter names).
|
|
pub fn parse_java(text: &str) -> MapperJava {
|
|
let package =
|
|
text.lines().find_map(|l| l.trim().strip_prefix("package ").map(|p| p.trim_end_matches(';').trim().to_owned()));
|
|
let Some(i) = text.find("interface ") else { return MapperJava::default() };
|
|
let simple: String = text[i + 10..].trim_start().chars().take_while(|c| ident(*c)).collect();
|
|
let name = Some(match package {
|
|
Some(p) => format!("{p}.{simple}"),
|
|
None => simple,
|
|
});
|
|
let body_start = text[i..].find('{').map_or(text.len(), |b| i + b + 1);
|
|
let mut methods = Vec::new();
|
|
let mut depth = 0;
|
|
let mut stmt_start = body_start;
|
|
let bytes: Vec<(usize, char)> = text[body_start..].char_indices().map(|(k, c)| (body_start + k, c)).collect();
|
|
for (k, c) in bytes {
|
|
match c {
|
|
'{' => depth += 1,
|
|
'}' if depth == 0 => break,
|
|
'}' => {
|
|
depth -= 1;
|
|
stmt_start = k + 1;
|
|
}
|
|
';' if depth == 0 => {
|
|
let stmt = &text[stmt_start..k];
|
|
if let Some(decl) = scan_declaration(stmt)
|
|
&& !stmt[..decl.parens.0].contains(" default ")
|
|
{
|
|
let (s, e) = (stmt_start + decl.name.0, stmt_start + decl.name.1);
|
|
methods.push((
|
|
Named { name: stmt[decl.name.0..decl.name.1].to_owned(), range: range_of(text, s, e) },
|
|
params_of(&stmt[decl.parens.0 + 1..decl.parens.1]),
|
|
));
|
|
}
|
|
stmt_start = k + 1;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
MapperJava { name, methods }
|
|
}
|
|
|
|
fn project_of(path: &str) -> Option<Project> {
|
|
host::projects()
|
|
.ok()?
|
|
.into_iter()
|
|
.filter(|p| p.root().is_ok_and(|r| path.starts_with(&r)))
|
|
.max_by_key(|p| p.root().map(|r| r.len()).unwrap_or(0))
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub struct Navigator {
|
|
/// project root → (namespace → XML path), with when it was built.
|
|
xml_index: BTreeMap<String, (Instant, BTreeMap<String, String>)>,
|
|
}
|
|
|
|
impl Navigator {
|
|
/// The XML mapping `namespace`, (re)indexing the project when needed.
|
|
fn xml_for(&mut self, project: &Project, namespace: &str) -> Option<(String, MapperXml)> {
|
|
let root = project.root().ok()?;
|
|
for attempt in 0..2 {
|
|
let fresh = self.xml_index.get(&root).is_some_and(|(at, _)| at.elapsed() < INDEX_TTL);
|
|
if !fresh || attempt == 1 {
|
|
let mut map = BTreeMap::new();
|
|
for path in project.find_files("**/*.xml", MAX_XML).ok()? {
|
|
let Ok(text) = project.read_file(&path) else { continue };
|
|
if !text.contains("<mapper") {
|
|
continue;
|
|
}
|
|
if let Some(ns) = parse_xml(&text).namespace {
|
|
map.insert(ns, path);
|
|
}
|
|
}
|
|
self.xml_index.insert(root.clone(), (Instant::now(), map));
|
|
}
|
|
let path = self.xml_index.get(&root).and_then(|(_, m)| m.get(namespace)).cloned();
|
|
if let Some(path) = path
|
|
&& let Ok(text) = project.read_file(&path)
|
|
{
|
|
let xml = parse_xml(&text);
|
|
if xml.namespace.as_deref() == Some(namespace) {
|
|
return Some((path, xml));
|
|
}
|
|
}
|
|
if !fresh {
|
|
break; // just rebuilt
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// The interface `namespace` names (`com.acme.UserMapper` → `**/com/acme/UserMapper.java`).
|
|
fn java_for(project: &Project, namespace: &str) -> Option<(String, MapperJava)> {
|
|
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()?);
|
|
(java.name.as_deref() == Some(namespace)).then_some((path, java))
|
|
}
|
|
|
|
/// Pairs of (name in this document, target location) for `doc`.
|
|
fn links(&mut self, doc: &Document) -> Vec<(Named, Location, String)> {
|
|
let Some(project) = project_of(&doc.path) else { return Vec::new() };
|
|
if doc.language == "xml" {
|
|
let xml = parse_xml(&doc.text);
|
|
let Some(ns) = xml.namespace else { return Vec::new() };
|
|
let Some((path, java)) = Self::java_for(&project, &ns) else { return Vec::new() };
|
|
let simple = ns.rsplit('.').next().unwrap_or(&ns).to_owned();
|
|
xml.statements
|
|
.into_iter()
|
|
.filter_map(|s| {
|
|
let (m, _) = java.methods.iter().find(|(m, _)| m.name == s.name)?;
|
|
let tip = format!("{simple}.{}()", m.name);
|
|
Some((s, Location { path: path.clone(), range: m.range }, tip))
|
|
})
|
|
.collect()
|
|
} else {
|
|
let java = parse_java(&doc.text);
|
|
let Some(name) = java.name else { return Vec::new() };
|
|
let Some((path, xml)) = self.xml_for(&project, &name) else { return Vec::new() };
|
|
let file = path.rsplit(['/', '\\']).next().unwrap_or(&path).to_owned();
|
|
java.methods
|
|
.into_iter()
|
|
.filter_map(|(m, _)| {
|
|
let s = xml.statements.iter().find(|s| s.name == m.name)?;
|
|
Some((m, Location { path: path.clone(), range: s.range }, format!("{file}: {}", s.name)))
|
|
})
|
|
.collect()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn contains(r: Range, p: Position) -> bool {
|
|
r.start <= p && p <= r.end
|
|
}
|
|
|
|
impl Providers for Navigator {
|
|
fn line_markers(&mut self, doc: &Document) -> Vec<LineMarker> {
|
|
let icon = if doc.language == "xml" { "implementing" } else { "implemented" };
|
|
self.links(doc)
|
|
.into_iter()
|
|
.map(|(n, target, tip)| LineMarker {
|
|
line: n.range.start.line,
|
|
icon: Some(icon.into()),
|
|
tooltip: format!("Go to {tip}"),
|
|
target: Some(target),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn definition(&mut self, doc: &Document, at: Position) -> Vec<Location> {
|
|
self.links(doc).into_iter().filter(|(n, ..)| contains(n.range, at)).map(|(_, l, _)| l).collect()
|
|
}
|
|
|
|
fn completion(&mut self, doc: &Document, at: Position, _trigger: Option<&str>) -> Vec<CompletionItem> {
|
|
if doc.language != "xml" {
|
|
return Vec::new();
|
|
}
|
|
let Some(cursor) = offset(&doc.text, at) else { return Vec::new() };
|
|
let before = &doc.text[..cursor];
|
|
let xml = parse_xml(&doc.text);
|
|
let Some(ns) = xml.namespace.clone() else { return Vec::new() };
|
|
let Some(project) = project_of(&doc.path) else { return Vec::new() };
|
|
let Some((_, java)) = Self::java_for(&project, &ns) else { return Vec::new() };
|
|
let line_start = before.rfind('\n').map_or(0, |i| i + 1);
|
|
let line = &before[line_start..];
|
|
// Inside `id="…` of a statement tag: the interface's unmapped methods.
|
|
if let Some(i) = line.rfind("id=\"")
|
|
&& !line[i + 4..].contains('"')
|
|
&& 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);
|
|
return java
|
|
.methods
|
|
.iter()
|
|
.filter(|(m, _)| m.name.starts_with(typed) && !xml.statements.iter().any(|s| s.name == m.name))
|
|
.map(|(m, _)| CompletionItem {
|
|
replace: Some(Range::new(start, at)),
|
|
detail: Some(ns.rsplit('.').next().unwrap_or(&ns).into()),
|
|
..CompletionItem::new(&m.name, CompletionKind::Method)
|
|
})
|
|
.collect();
|
|
}
|
|
// After `#{`: the enclosing statement's parameters.
|
|
if let Some(i) = before.rfind("#{")
|
|
&& !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 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);
|
|
return params
|
|
.iter()
|
|
.filter(|p| p.starts_with(typed))
|
|
.map(|p| CompletionItem {
|
|
replace: Some(Range::new(start, at)),
|
|
..CompletionItem::new(p, CompletionKind::Variable)
|
|
})
|
|
.collect();
|
|
}
|
|
Vec::new()
|
|
}
|
|
}
|
|
|
|
impl Plugin for Navigator {
|
|
fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
|
|
Err(Error::NotFound(format!("command {id}")))
|
|
}
|
|
}
|
|
|
|
register_plugin!(Navigator, providers);
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use openidea_plugin::mock::MockHost;
|
|
|
|
const JAVA: &str = "package com.acme;\n\nimport org.apache.ibatis.annotations.Param;\n\npublic interface UserMapper {\n User findById(@Param(\"id\") long id);\n\n int insert(User user);\n\n default void helper() {}\n\n List<User> search(String name, int limit);\n}\n";
|
|
const XML: &str = "<?xml version=\"1.0\"?>\n<mapper namespace=\"com.acme.UserMapper\">\n <select id=\"findById\" resultType=\"User\">\n select * from users where id = #{id}\n </select>\n <insert id=\"insert\">\n insert into users values (#{user})\n </insert>\n <select id=\"\">\n </select>\n</mapper>\n";
|
|
|
|
#[test]
|
|
fn parses_both_sides() {
|
|
let java = parse_java(JAVA);
|
|
assert_eq!(java.name.as_deref(), Some("com.acme.UserMapper"));
|
|
let names: Vec<_> = java.methods.iter().map(|(m, p)| (m.name.as_str(), p.clone())).collect();
|
|
assert_eq!(
|
|
names,
|
|
[
|
|
("findById", vec!["id".to_owned()]),
|
|
("insert", vec!["user".into()]),
|
|
("search", vec!["name".into(), "limit".into()])
|
|
]
|
|
);
|
|
assert_eq!(java.methods[0].0.range, Range::new(Position::new(5, 9), Position::new(5, 17)));
|
|
let xml = parse_xml(XML);
|
|
assert_eq!(xml.namespace.as_deref(), Some("com.acme.UserMapper"));
|
|
assert_eq!(xml.statements.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["findById", "insert", ""]);
|
|
assert_eq!(xml.statements[0].range, Range::new(Position::new(2, 14), Position::new(2, 22)));
|
|
}
|
|
|
|
const ANNOTATED: &str = concat!(
|
|
"package com.acme;\n\n",
|
|
"import org.apache.ibatis.annotations.Param;\n\n",
|
|
"public interface UserMapper {\n",
|
|
" @Select(\"SELECT (a, b) FROM users WHERE c = ')' OR d = 'x, y'\")\n",
|
|
" List<User> selectAnnotated(Long id);\n\n",
|
|
" @Cacheable(key = @Key(value = \"x(y)\", of = {@Part(\"a)b\"), @Part(\"c,d\")}))\n",
|
|
" @Override\n",
|
|
" // a comment (with parens) before the name\n",
|
|
" User findAnnotated(@Param(\"id\") Long id, @Size(min = 1, max = 5) String name);\n\n",
|
|
" List<User> selectPlain(Long id);\n",
|
|
"}\n"
|
|
);
|
|
|
|
const ANNOTATED_XML: &str = "<?xml version=\"1.0\"?>\n<mapper namespace=\"com.acme.UserMapper\">\n <select id=\"selectAnnotated\" resultType=\"User\">SELECT (a, b) FROM users</select>\n <select id=\"findAnnotated\">SELECT 1</select>\n</mapper>\n";
|
|
|
|
fn setup() -> MockHost {
|
|
let host = MockHost::install();
|
|
host.add_file("src/main/java/com/acme/UserMapper.java", JAVA);
|
|
host.add_file("src/main/resources/mapper/UserMapper.xml", XML);
|
|
host.add_file("pom.xml", "<project/>");
|
|
host
|
|
}
|
|
|
|
fn annotated_setup() -> MockHost {
|
|
let host = MockHost::install();
|
|
host.add_file("src/main/java/com/acme/UserMapper.java", ANNOTATED);
|
|
host.add_file("src/main/resources/mapper/UserMapper.xml", ANNOTATED_XML);
|
|
host.add_file("pom.xml", "<project/>");
|
|
host
|
|
}
|
|
|
|
/// Methods behind method-level annotations keep their names, parameters and
|
|
/// positions: the scanner skips comments, strings and annotation arguments
|
|
/// (nested annotations, `(` `)` `{` `}` `,` in strings) before the name.
|
|
#[test]
|
|
fn parses_methods_with_annotations() {
|
|
let java = parse_java(ANNOTATED);
|
|
let names: Vec<_> = java.methods.iter().map(|(m, p)| (m.name.as_str(), p.clone())).collect();
|
|
assert_eq!(
|
|
names,
|
|
[
|
|
("selectAnnotated", vec!["id".to_owned()]),
|
|
("findAnnotated", vec!["id".to_owned(), "name".to_owned()],),
|
|
("selectPlain", vec!["id".to_owned()])
|
|
],
|
|
"annotation names must not become method names, @Param wins, argument commas must not split a parameter"
|
|
);
|
|
// ` List<User> ` before the name on its line (UTF-16 columns).
|
|
assert_eq!(java.methods[0].0.range, Range::new(Position::new(6, 15), Position::new(6, 30)));
|
|
assert_eq!(java.methods[1].0.range, Range::new(Position::new(11, 9), Position::new(11, 22)));
|
|
}
|
|
|
|
/// A multibyte annotation string on the name's line keeps UTF-16 columns:
|
|
/// `中文` is one unit each and `😀` two, while bytes would run ahead.
|
|
#[test]
|
|
fn keeps_utf16_columns_around_multibyte_annotations() {
|
|
let text = "package com.acme;\n\npublic interface UserMapper {\n @Select(\"中文 😀 注释\") List<User> find(Long id);\n}\n";
|
|
let java = parse_java(text);
|
|
assert_eq!(java.methods.len(), 1);
|
|
assert_eq!(java.methods[0].0.name, "find");
|
|
// `"中文 😀 注释"` costs 10 UTF-16 units (😀 two); `find` starts at 4 + 8 + 1 + 10 + 1 + 11 = 35.
|
|
assert_eq!(java.methods[0].0.range, Range::new(Position::new(3, 35), Position::new(3, 39)));
|
|
assert_eq!(java.methods[0].1, ["id".to_owned()]);
|
|
}
|
|
|
|
/// Annotated methods still get both directions of navigation.
|
|
#[test]
|
|
fn navigates_annotated_methods() {
|
|
let _host = annotated_setup();
|
|
let mut n = Navigator::default();
|
|
let java = doc("/project/src/main/java/com/acme/UserMapper.java", "java", ANNOTATED);
|
|
let markers = n.line_markers(&java);
|
|
assert_eq!(markers.iter().map(|m| m.line).collect::<Vec<_>>(), [6, 11], "selectPlain has no statement");
|
|
assert_eq!(markers[1].tooltip, "Go to UserMapper.xml: findAnnotated");
|
|
|
|
let xml = doc("/project/src/main/resources/mapper/UserMapper.xml", "xml", ANNOTATED_XML);
|
|
let to_java = n.definition(&xml, Position::new(2, 20));
|
|
assert_eq!(to_java.len(), 1);
|
|
assert_eq!(
|
|
(to_java[0].path.ends_with("UserMapper.java"), to_java[0].range.start),
|
|
(true, Position::new(6, 15)),
|
|
"the XML statement jumps back to the annotated method"
|
|
);
|
|
}
|
|
|
|
fn doc(path: &str, language: &str, text: &str) -> Document {
|
|
Document { path: path.into(), language: language.into(), version: 1, text: text.into() }
|
|
}
|
|
|
|
#[test]
|
|
fn navigates_both_ways() {
|
|
let _host = setup();
|
|
let mut n = Navigator::default();
|
|
let java = doc("/project/src/main/java/com/acme/UserMapper.java", "java", JAVA);
|
|
let markers = n.line_markers(&java);
|
|
assert_eq!(markers.iter().map(|m| m.line).collect::<Vec<_>>(), [5, 7], "search has no statement");
|
|
assert_eq!(markers[0].tooltip, "Go to UserMapper.xml: findById");
|
|
let target = markers[0].target.as_ref().unwrap();
|
|
assert_eq!(
|
|
(target.path.as_str(), target.range.start),
|
|
("/project/src/main/resources/mapper/UserMapper.xml", Position::new(2, 14))
|
|
);
|
|
|
|
let xml = doc("/project/src/main/resources/mapper/UserMapper.xml", "xml", XML);
|
|
let to_java = n.definition(&xml, Position::new(5, 15));
|
|
assert_eq!(to_java.len(), 1);
|
|
assert_eq!((to_java[0].path.ends_with("UserMapper.java"), to_java[0].range.start), (true, Position::new(7, 8)));
|
|
assert!(n.definition(&xml, Position::new(3, 5)).is_empty(), "not on an id");
|
|
assert_eq!(n.line_markers(&xml)[0].tooltip, "Go to UserMapper.findById()");
|
|
}
|
|
|
|
#[test]
|
|
fn method_annotations_do_not_swallow_the_method_name() {
|
|
// A method-level annotation with arguments: `stmt.find('(')` would land
|
|
// inside `@DataPermission(…)` and read `DataPermission` as the name.
|
|
let java = parse_java(
|
|
"package com.acme;\n\npublic interface SecuredMapper {\n @DataPermission(column = \"id\", scope = (@Scope(\"self\")))\n List<User> findAll(@Param(\"org\") String org);\n\n @org.acme.Check(value = \"(nested)\")\n int count();\n}\n",
|
|
);
|
|
let names: Vec<_> = java.methods.iter().map(|(m, p)| (m.name.as_str(), p.clone())).collect();
|
|
assert_eq!(names, [("findAll", vec!["org".to_owned()]), ("count", Vec::<String>::new())]);
|
|
}
|
|
|
|
#[test]
|
|
fn completes_ids_and_parameters() {
|
|
let _host = setup();
|
|
let mut n = Navigator::default();
|
|
let xml = doc("/project/src/main/resources/mapper/UserMapper.xml", "xml", XML);
|
|
let ids = n.completion(&xml, Position::new(8, 14), None);
|
|
assert_eq!(ids.iter().map(|c| c.label.as_str()).collect::<Vec<_>>(), ["search"], "unmapped methods only");
|
|
let text = XML.replace("#{id}", "#{");
|
|
let xml = doc("/project/src/main/resources/mapper/UserMapper.xml", "xml", &text);
|
|
let params = n.completion(&xml, Position::new(3, 38), None);
|
|
assert_eq!(params.iter().map(|c| c.label.as_str()).collect::<Vec<_>>(), ["id"]);
|
|
}
|
|
}
|