Skip to content

Commit 23a2b87

Browse files
authored
feat(js): base for js completions (#79)
* feat(js): base for js completions support for this.orm.call #67 * feat(js): use existing methods
1 parent 03123cb commit 23a2b87

File tree

9 files changed

+237
-2
lines changed

9 files changed

+237
-2
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/js.rs

Lines changed: 157 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,33 @@
11
use std::borrow::Cow;
22

33
use tower_lsp_server::lsp_types::*;
4+
use tree_sitter::{QueryCursor, Tree};
45

56
use crate::prelude::*;
67

78
use crate::backend::Backend;
89
use crate::backend::Text;
9-
use crate::index::JsQuery;
10+
use crate::index::{_G, JsQuery};
11+
use crate::model::PropertyKind;
12+
use crate::utils::{ByteOffset, MaxVec, RangeExt, span_conv};
13+
use tracing::instrument;
14+
use ts_macros::query;
15+
16+
query! {
17+
#[lang = "tree_sitter_javascript"]
18+
OrmCallQuery(OrmObject, CallMethod, ModelArg, MethodArg);
19+
// Match this.orm.call('model', 'method')
20+
(call_expression
21+
function: (member_expression
22+
object: (member_expression
23+
object: (this)
24+
property: (property_identifier) @ORM_OBJECT (#eq? @ORM_OBJECT "orm"))
25+
property: (property_identifier) @CALL_METHOD (#eq? @CALL_METHOD "call"))
26+
arguments: (arguments
27+
. (string) @MODEL_ARG
28+
. ","
29+
. (string) @METHOD_ARG))
30+
}
1031

1132
impl Backend {
1233
pub fn on_change_js(
@@ -116,4 +137,139 @@ impl Backend {
116137

117138
Ok(None)
118139
}
140+
141+
#[instrument(skip_all)]
142+
pub async fn js_completions(
143+
&self,
144+
params: CompletionParams,
145+
ast: Tree,
146+
rope: RopeSlice<'_>,
147+
) -> anyhow::Result<Option<CompletionResponse>> {
148+
let uri = &params.text_document_position.text_document.uri;
149+
let position = params.text_document_position.position;
150+
let Ok(ByteOffset(offset)) = rope_conv(position, rope) else {
151+
return Err(errloc!("could not find offset for {}", uri.path().as_str()));
152+
};
153+
154+
let contents = Cow::from(rope);
155+
let contents = contents.as_bytes();
156+
let query = OrmCallQuery::query();
157+
let mut cursor = QueryCursor::new();
158+
159+
// Find the orm.call node that contains the cursor position
160+
for match_ in cursor.matches(query, ast.root_node(), contents) {
161+
let mut model_arg_node = None;
162+
let mut method_arg_node = None;
163+
164+
for capture in match_.captures {
165+
match OrmCallQuery::from(capture.index) {
166+
Some(OrmCallQuery::ModelArg) => {
167+
model_arg_node = Some(capture.node);
168+
}
169+
Some(OrmCallQuery::MethodArg) => {
170+
method_arg_node = Some(capture.node);
171+
}
172+
_ => {}
173+
}
174+
}
175+
176+
// Check if cursor is within the model argument
177+
if let Some(model_node) = model_arg_node {
178+
let range = model_node.byte_range();
179+
if range.contains(&offset) {
180+
// Extract the current prefix (excluding quotes)
181+
let inner_range = range.shrink(1);
182+
let prefix = String::from_utf8_lossy(&contents[inner_range.start..offset]);
183+
let lsp_range = span_conv(model_node.range());
184+
let mut items = MaxVec::new(100);
185+
self.index.complete_model(&prefix, lsp_range, &mut items)?;
186+
187+
return Ok(Some(CompletionResponse::List(CompletionList {
188+
is_incomplete: false,
189+
items: items.into_inner(),
190+
})));
191+
}
192+
}
193+
194+
// Check if cursor is within the method argument
195+
if let Some(method_node) = method_arg_node {
196+
let range = method_node.byte_range();
197+
if range.contains(&offset) {
198+
// Extract the model name from the first argument
199+
if let Some(model_node) = model_arg_node {
200+
let model_range = model_node.byte_range().shrink(1);
201+
let model_name = String::from_utf8_lossy(&contents[model_range]).to_string();
202+
203+
// Extract the current method prefix (excluding quotes)
204+
let inner_range = range.clone().shrink(1);
205+
let prefix = String::from_utf8_lossy(&contents[inner_range.start..offset]);
206+
207+
let byte_range = ByteOffset(range.start)..ByteOffset(range.end);
208+
209+
let mut items = MaxVec::new(100);
210+
self.index.complete_property_name(
211+
&prefix,
212+
byte_range,
213+
model_name,
214+
rope,
215+
Some(PropertyKind::Method),
216+
true,
217+
&mut items,
218+
)?;
219+
220+
return Ok(Some(CompletionResponse::List(CompletionList {
221+
is_incomplete: false,
222+
items: items.into_inner(),
223+
})));
224+
}
225+
}
226+
}
227+
228+
// Check if cursor is in a position where we should start a new string argument
229+
// This handles cases where the user is typing after the comma but hasn't started the string yet
230+
if let Some(model_node) = model_arg_node {
231+
let model_end = model_node.byte_range().end;
232+
// Look for comma after model argument
233+
let mut i = model_end;
234+
while i < contents.len() && contents[i].is_ascii_whitespace() {
235+
i += 1;
236+
}
237+
if i < contents.len() && contents[i] == b',' {
238+
i += 1;
239+
// Skip whitespace after comma
240+
while i < contents.len() && contents[i].is_ascii_whitespace() {
241+
i += 1;
242+
}
243+
// If cursor is at or after this position and before any method argument
244+
if offset >= i
245+
&& (method_arg_node.is_none() || offset < method_arg_node.unwrap().byte_range().start)
246+
{
247+
// We're completing the method name
248+
let model_range = model_node.byte_range().shrink(1);
249+
let model_name = String::from_utf8_lossy(&contents[model_range]).to_string();
250+
251+
let synthetic_range = ByteOffset(i)..ByteOffset(offset.max(i));
252+
253+
let mut items = MaxVec::new(100);
254+
self.index.complete_property_name(
255+
"",
256+
synthetic_range,
257+
model_name,
258+
rope,
259+
Some(PropertyKind::Method),
260+
true,
261+
&mut items,
262+
)?;
263+
264+
return Ok(Some(CompletionResponse::List(CompletionList {
265+
is_incomplete: false,
266+
items: items.into_inner(),
267+
})));
268+
}
269+
}
270+
}
271+
}
272+
273+
Ok(None)
274+
}
119275
}

src/server.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,24 @@ impl LanguageServer for Backend {
417417
Ok(None)
418418
}
419419
}
420+
} else if ext == "js" {
421+
let ast = {
422+
let Some(ast) = self.ast_map.get(uri.path().as_str()) else {
423+
debug!("Bug: did not build AST for {}", uri.path().as_str());
424+
return Ok(None);
425+
};
426+
ast.value().clone()
427+
};
428+
let completions = self.js_completions(params, ast, rope.slice(..)).await;
429+
match completions {
430+
Ok(ret) => Ok(ret),
431+
Err(err) => {
432+
self.client
433+
.show_message(MessageType::ERROR, format!("error during js completion:\n{err}"))
434+
.await;
435+
Ok(None)
436+
}
437+
}
420438
} else {
421439
debug!("(completion) unsupported {}", uri.path().as_str());
422440
Ok(None)

testing/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ build = "build.rs"
1212
async-lsp = { version = "0.2.2", features = ["tokio", "forward"] }
1313
tokio-util = { version = "0.7.14", features = ["compat"] }
1414
tree-sitter-xml = "0.7.0"
15+
tree-sitter-javascript = "0.23.1"
1516
rstest = "0.25.0"
1617
ts-macros.workspace = true
1718
pretty_assertions.workspace = true
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"module": {
3+
"roots": [
4+
"."
5+
]
6+
}
7+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"name": "test_module"}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/** @odoo-module **/
2+
3+
export class TestWidget extends Component {
4+
static template = "test_module.TestWidget";
5+
6+
async onButtonClick() {
7+
const result = await this.orm.call('test', 'test_method');
8+
// ^complete test.model
9+
10+
// Test method name completion
11+
const result2 = await this.orm.call('test.model', 'an');
12+
// ^complete another_method
13+
}
14+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from odoo import models, fields, api
2+
3+
4+
class TestModel(models.Model):
5+
_name = "test.model"
6+
_description = "Test Model"
7+
8+
name = fields.Char()
9+
10+
def test_method(self):
11+
return True
12+
13+
def another_method(self, param):
14+
return param

testing/src/tests.rs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ async fn fixture_test(#[files("fixtures/*")] root: PathBuf) {
5555
// <!> collect expected samples
5656
let mut expected = gather_expected(&root, TestLanguages::Python);
5757
expected.extend(gather_expected(&root, TestLanguages::Xml));
58+
expected.extend(gather_expected(&root, TestLanguages::JavaScript));
5859
expected.retain(|_, expected| {
5960
!expected.complete.is_empty()
6061
|| !expected.diag.is_empty()
@@ -77,6 +78,7 @@ async fn fixture_test(#[files("fixtures/*")] root: PathBuf) {
7778
let language_id = match path.extension().unwrap().to_string_lossy().as_ref() {
7879
"py" => "python",
7980
"xml" => "xml",
81+
"js" => "javascript",
8082
unk => panic!("unknown file extension {unk}"),
8183
}
8284
.to_string();
@@ -318,6 +320,21 @@ fn xml_query() -> &'static Query {
318320
QUERY.get_or_init(|| Query::new(&tree_sitter_xml::LANGUAGE_XML.into(), XML_QUERY).unwrap())
319321
}
320322

323+
fn js_query() -> &'static Query {
324+
static QUERY: OnceLock<Query> = OnceLock::new();
325+
const JS_QUERY: &str = r#"
326+
((comment) @diag
327+
(#match? @diag "\\^diag "))
328+
329+
((comment) @complete
330+
(#match? @complete "\\^complete "))
331+
332+
((comment) @def
333+
(#match? @def "\\^def"))
334+
"#;
335+
QUERY.get_or_init(|| Query::new(&tree_sitter_javascript::LANGUAGE.into(), JS_QUERY).unwrap())
336+
}
337+
321338
#[derive(Default)]
322339
struct Expected {
323340
diag: Vec<(Position, String)>,
@@ -329,6 +346,7 @@ struct Expected {
329346
enum TestLanguages {
330347
Python,
331348
Xml,
349+
JavaScript,
332350
}
333351

334352
enum InspectType {}
@@ -342,6 +360,7 @@ fn gather_expected(root: &Path, lang: TestLanguages) -> HashMap<PathBuf, Expecte
342360
let (glob, query, language) = match lang {
343361
TestLanguages::Python => ("**/*.py", PyExpected::query as fn() -> _, tree_sitter_python::LANGUAGE),
344362
TestLanguages::Xml => ("**/*.xml", xml_query as _, tree_sitter_xml::LANGUAGE_XML),
363+
TestLanguages::JavaScript => ("**/*.js", js_query as _, tree_sitter_javascript::LANGUAGE),
345364
};
346365

347366
let path = root.join(glob).to_string_lossy().into_owned();
@@ -358,7 +377,11 @@ fn gather_expected(root: &Path, lang: TestLanguages) -> HashMap<PathBuf, Expecte
358377

359378
for (match_, _) in cursor.captures(query(), ast.root_node(), &contents[..]) {
360379
for capture in match_.captures {
361-
let skip = if matches!(lang, TestLanguages::Xml) { 4 } else { 1 };
380+
let skip = match lang {
381+
TestLanguages::Xml => 4,
382+
TestLanguages::JavaScript => 2, // Skip "//"
383+
TestLanguages::Python => 1, // Skip "#"
384+
};
362385
let text = &contents[capture.node.byte_range()][skip..];
363386
let Some(idx) = text.iter().position(|ch| *ch == b'^') else {
364387
continue;

0 commit comments

Comments
 (0)