Skip to main content

htmx_lsp2/
position.rs

1use std::{
2    collections::HashMap,
3    sync::{Arc, Mutex},
4};
5
6use dashmap::DashMap;
7use ropey::Rope;
8use tower_lsp::lsp_types::TextDocumentPositionParams;
9use tree_sitter::{Node, Point};
10
11use crate::{
12    htmx_tree_sitter::LspFiles,
13    init_hx::LangType,
14    query_helper::{query_name, query_value, HTMLQueries, HTMLQuery},
15};
16
17/// Helpful enum when making TreeSitter queries.
18/// Goto implementation and code action request uses Definition enum.
19#[derive(PartialEq, Eq)]
20pub enum QueryType {
21    Hover,
22    Completion,
23    Definition,
24}
25
26/// TreeSitter queries saves every capture as `CaptureDetails`.
27#[derive(Debug, Clone)]
28pub struct CaptureDetails {
29    pub start_position: Point,
30    pub end_position: Point,
31    pub value: String,
32}
33
34/// After processing `CaptureDetails`, we can get precise results about
35/// client event in form of `AttributeName` or `AttributeValue`.
36/// This data is later used in all language server requests.
37/// Hover on attribute name doesn't capture other part of element, only attribute
38/// name without value.
39#[derive(Debug, Clone, PartialEq)]
40pub enum Position {
41    AttributeName(String),
42    AttributeValue {
43        name: String,
44        value: String,
45        definition: Option<PositionDefinition>,
46    },
47}
48
49#[derive(Debug, Clone, PartialEq)]
50pub struct PositionDefinition {
51    pub point: Point,
52    pub start: usize,
53}
54
55impl PositionDefinition {
56    pub fn new(start: usize, point: Point) -> Self {
57        Self { point, start }
58    }
59}
60
61/// Based on current position in document get `Position`.
62pub fn get_position_from_lsp_completion(
63    text_params: &TextDocumentPositionParams,
64    text: &DashMap<String, Rope>,
65    uri: String,
66    query_type: QueryType,
67    lsp_files: &Arc<Mutex<LspFiles>>,
68    query: &HTMLQueries,
69) -> Option<Position> {
70    let text = text.get(&uri)?;
71    let text = text.to_string();
72    let pos = text_params.position;
73
74    if let Ok(lsp_files) = lsp_files.lock() {
75        if let Some(index) = lsp_files.get_index(&uri) {
76            lsp_files.query_position(index, &text, query_type, pos, query)
77        } else if let Some(index) = lsp_files.add_file(String::from(&uri)) {
78            lsp_files.add_tree(index, LangType::Template, &text, None);
79            lsp_files.query_position(index, &text, query_type, pos, query)
80        } else {
81            None
82        }
83    } else {
84        None
85    }
86}
87
88fn find_element_referent_to_current_node(node: Node<'_>) -> Option<Node<'_>> {
89    if node.kind() == "element" || node.kind() == "fragment" {
90        return Some(node);
91    }
92
93    return find_element_referent_to_current_node(node.parent()?);
94}
95
96/// Main function for querying HTML TreeSitter. It can be used for testing.
97pub fn query_position(
98    root: Node<'_>,
99    source: &str,
100    trigger_point: Point,
101    query_type: QueryType,
102    query: &HTMLQueries,
103) -> Option<Position> {
104    let closest_node = root.descendant_for_point_range(trigger_point, trigger_point)?;
105    let element = find_element_referent_to_current_node(closest_node)?;
106
107    let name = query_name(
108        element,
109        source,
110        trigger_point,
111        &query_type,
112        query.get(HTMLQuery::Name),
113    );
114    if name.is_some() {
115        return name;
116    }
117    query_value(
118        element,
119        source,
120        trigger_point,
121        &query_type,
122        query.get(HTMLQuery::Value),
123    )
124}
125
126/// Debug capture details.
127#[allow(dead_code)]
128pub fn dbg_props(props: &HashMap<String, CaptureDetails>) {
129    for i in props {
130        dbg!(i);
131    }
132}
133
134/// Function responsible for getting precise `Position` for completion in HTML TreeSitter query.
135pub fn completion_position(props: HashMap<String, CaptureDetails>) -> Option<Position> {
136    let attr_name = props.get("attr_name")?;
137
138    if let Some(_capture) = props.get("with_attr_name_with_equals_err") {
139        None
140    } else if let Some(_capture) = props.get("with_attr_name_without_value_t") {
141        Some(Position::AttributeName(attr_name.value.to_string()))
142    } else if let Some(_capture) = props.get("with_attr_value_empty") {
143        Some(Position::AttributeValue {
144            name: attr_name.value.to_string(),
145            value: String::new(),
146            definition: None,
147        })
148    } else if let Some(_capture) = props.get("with_attr_value_not_empty") {
149        Some(Position::AttributeValue {
150            name: attr_name.value.to_string(),
151            value: String::new(),
152            definition: None,
153        })
154    } else {
155        props
156            .get("with_error_with_value_t_no_second_quote")
157            .map(|_capture| Position::AttributeValue {
158                name: attr_name.value.to_string(),
159                value: String::new(),
160                definition: None,
161            })
162    }
163}
164
165/// Checks if client_point is in attribute name or attribute_value range.
166pub fn hover_position(
167    props: HashMap<String, CaptureDetails>,
168    client_point: Point,
169) -> Option<Position> {
170    let attr_name = props.get("attr_name")?;
171    if let Some(capture) = props.get("with_attr_value_not_empty") {
172        if client_point > capture.end_position {
173            return None;
174        }
175        let attr_value = props.get("attr_value");
176        if let Some(capture) = attr_value {
177            if client_point >= attr_name.end_position {
178                return Some(Position::AttributeValue {
179                    name: attr_name.value.to_string(),
180                    value: capture.value.to_string(),
181                    definition: None,
182                });
183            }
184        }
185        if client_point <= attr_name.end_position {
186            return Some(Position::AttributeName(attr_name.value.to_string()));
187        }
188        None
189    } else if let Some(capture) = props.get("with_attr_value_empty") {
190        if client_point > capture.end_position {
191            return None;
192        }
193        let attr_value = props.get("attr_value");
194        match attr_value {
195            Some(capture) => Some(Position::AttributeValue {
196                name: attr_name.value.to_string(),
197                value: capture.value.to_string(),
198                definition: None,
199            }),
200            None => Some(Position::AttributeName(attr_name.value.to_string())),
201        }
202    } else {
203        None
204    }
205}
206
207#[cfg(test)]
208mod tests1 {
209    use tree_sitter::{Parser, Point};
210
211    use crate::{
212        position::{query_position, Position, QueryType},
213        query_helper::{query_props, HTMLQueries, Queries},
214    };
215
216    fn prepare_tree(text: &str) -> tree_sitter::Tree {
217        let language = tree_sitter_html::language();
218        let mut parser = Parser::new();
219
220        parser
221            .set_language(language)
222            .expect("could not load html grammer");
223
224        parser.parse(text, None).expect("not to fail")
225    }
226
227    fn prepare_python_tree(text: &str) -> tree_sitter::Tree {
228        let language = tree_sitter_python::language();
229        let mut parser = Parser::new();
230
231        parser
232            .set_language(language)
233            .expect("could not load html grammer");
234
235        parser.parse(text, None).expect("not to fail")
236    }
237
238    #[test]
239    fn suggests_attr_names_when_starting_tag() {
240        let text = r##"<div hx- ></div>"##;
241
242        let tree = prepare_tree(text);
243
244        let query = HTMLQueries::default();
245        let matches = query_position(
246            tree.root_node(),
247            text,
248            Point::new(0, 8),
249            QueryType::Completion,
250            &query,
251        );
252        assert_eq!(matches, Some(Position::AttributeName("hx-".to_string())));
253    }
254
255    #[test]
256    fn does_not_suggest_when_quote_not_initiated() {
257        let text = r##"<div hx-swap= ></div>"##;
258
259        let tree = prepare_tree(text);
260
261        let query = HTMLQueries::default();
262        let matches = query_position(
263            tree.root_node(),
264            text,
265            Point::new(0, 13),
266            QueryType::Completion,
267            &query,
268        );
269
270        assert_eq!(matches, None);
271    }
272
273    #[test]
274    fn suggests_attr_values_when_starting_quote_value() {
275        let text = r#"<div hx-swap=" ></div>"#;
276
277        let tree = prepare_tree(text);
278
279        let query = HTMLQueries::default();
280        let matches = query_position(
281            tree.root_node(),
282            text,
283            Point::new(0, 14),
284            QueryType::Completion,
285            &query,
286        );
287
288        assert_eq!(
289            matches,
290            Some(Position::AttributeValue {
291                name: "hx-swap".to_string(),
292                value: "".to_string(),
293                definition: None
294            })
295        );
296    }
297
298    #[test]
299    fn suggests_attr_values_when_open_and_closed_quotes() {
300        let text = r#"<div hx-swap=""></div>"#;
301
302        let tree = prepare_tree(text);
303
304        let query = HTMLQueries::default();
305        let matches = query_position(
306            tree.root_node(),
307            text,
308            Point::new(0, 13),
309            QueryType::Completion,
310            &query,
311        );
312
313        assert_eq!(
314            matches,
315            Some(Position::AttributeValue {
316                name: "hx-swap".to_string(),
317                value: "".to_string(),
318                definition: None
319            })
320        );
321    }
322
323    #[test]
324    fn suggests_attr_values_once_opening_quotes_in_between_tags() {
325        let text = r#"<div id="fa" hx-swap="hx-swap" hx-swap="hx-swap">
326      <span hx-target="
327      <button>Click me</button>
328    </div>
329    "#;
330
331        let tree = prepare_tree(text);
332
333        let query = HTMLQueries::default();
334        let matches = query_position(
335            tree.root_node(),
336            text,
337            Point::new(1, 23),
338            QueryType::Completion,
339            &query,
340        );
341
342        assert_eq!(
343            matches,
344            Some(Position::AttributeValue {
345                name: "hx-target".to_string(),
346                value: "".to_string(),
347                definition: None
348            })
349        );
350    }
351
352    #[test]
353    fn suggests_attr_names_for_incomplete_attr_in_between_tags() {
354        let text = r#"<div id="fa" hx-target="this" hx-swap="hx-swap">
355      <span hx-
356      <button>Click me</button>
357    </div>
358    "#;
359
360        let tree = prepare_tree(text);
361
362        let query = HTMLQueries::default();
363        let matches = query_position(
364            tree.root_node(),
365            text,
366            Point::new(1, 14),
367            QueryType::Completion,
368            &query,
369        );
370
371        assert_eq!(matches, Some(Position::AttributeName("hx-".to_string())));
372    }
373
374    #[test]
375    fn matches_more_than_one_attribute() {
376        let text = r#"<div hx-get="/foo" hx-target="this" hx- ></div>"#;
377
378        let tree = prepare_tree(text);
379
380        let query = HTMLQueries::default();
381        let matches = query_position(
382            tree.root_node(),
383            text,
384            Point::new(0, 39),
385            QueryType::Completion,
386            &query,
387        );
388
389        assert_eq!(matches, Some(Position::AttributeName("hx-".to_string())));
390    }
391
392    #[test]
393    fn suggests_attr_value_when_attr_is_empty_and_in_between_attributes() {
394        let text = r##"<div hx-get="/foo" hx-target="" hx-swap="#swap"></div>
395    "##;
396
397        let tree = prepare_tree(text);
398
399        let query = HTMLQueries::default();
400        let matches = query_position(
401            tree.root_node(),
402            text,
403            Point::new(0, 30),
404            QueryType::Completion,
405            &query,
406        );
407
408        assert_eq!(
409            matches,
410            Some(Position::AttributeValue {
411                name: "hx-target".to_string(),
412                value: "".to_string(),
413                definition: None
414            })
415        );
416    }
417
418    #[test]
419    fn suggests_attr_values_for_incoplete_quoted_attr_when_in_between_attributes() {
420        let text = r##"<div hx-get="/foo" hx-target=" hx-swap="#swap"></div>"##;
421
422        let tree = prepare_tree(text);
423
424        let query = HTMLQueries::default();
425        let matches = query_position(
426            tree.root_node(),
427            text,
428            Point::new(0, 30),
429            QueryType::Completion,
430            &query,
431        );
432
433        assert_eq!(
434            matches,
435            Some(Position::AttributeValue {
436                name: "hx-target".to_string(),
437                value: "".to_string(),
438                definition: None
439            })
440        );
441    }
442
443    #[test]
444    fn suggests_attr_names_for_incoplete_quoted_value_in_between_attributes() {
445        let text = r##"<div hx-get="/foo" hx- hx-swap="#swap"></div>
446        <span class="foo" />"##;
447
448        let tree = prepare_tree(text);
449
450        let query = HTMLQueries::default();
451        let matches = query_position(
452            tree.root_node(),
453            text,
454            Point::new(0, 22),
455            QueryType::Completion,
456            &query,
457        );
458
459        assert_eq!(matches, Some(Position::AttributeName("hx-".to_string())));
460    }
461
462    #[test]
463    fn suggests_attribute_keys_when_half_completeded() {
464        let text = r##"<div hx-get="/foo" hx-t hx-swap="#swap"></div>
465        <span class="foo" />"##;
466
467        let tree = prepare_tree(text);
468
469        let query = HTMLQueries::default();
470        let matches = query_position(
471            tree.root_node(),
472            text,
473            Point::new(0, 23),
474            QueryType::Completion,
475            &query,
476        );
477
478        assert_eq!(matches, Some(Position::AttributeName("hx-t".to_string())));
479    }
480
481    #[test]
482    fn suggests_values_for_already_filled_attributes() {
483        let text = r##"<div hx-get="/foo" hx-target="find " hx-swap="#swap"></div>"##;
484
485        let tree = prepare_tree(text);
486
487        let query = HTMLQueries::default();
488        let matches = query_position(
489            tree.root_node(),
490            text,
491            Point::new(0, 35),
492            QueryType::Hover,
493            &query,
494        );
495
496        assert_eq!(
497            matches,
498            Some(Position::AttributeValue {
499                name: "hx-target".to_string(),
500                value: "find ".to_string(),
501                definition: None
502            })
503        );
504    }
505
506    #[test]
507    fn does_not_suggest_when_cursor_isnt_within_a_htmx_attribute() {
508        let text = r#"<div hx-get="/foo"  class="p-4" ></div>"#;
509
510        let tree = prepare_tree(text);
511
512        let query = HTMLQueries::default();
513        let matches = query_position(
514            tree.root_node(),
515            text,
516            Point::new(0, 24),
517            QueryType::Hover,
518            &query,
519        );
520
521        assert_eq!(matches, None);
522    }
523
524    #[test]
525    fn hover_hx_tags() {
526        let cases = [
527            (
528                r#"<div hx-get="/foo" class="p-4" hx-target="closest" ></div>"#,
529                Point::new(0, 37),
530                Some(Position::AttributeName(String::from("hx-target"))),
531            ),
532            (
533                r#"<div hx-get="" class="p-4" hx-target="" ></div>"#,
534                Point::new(0, 9),
535                Some(Position::AttributeName(String::from("hx-get"))),
536            ),
537            (
538                r#"<div hx-get="/foo" hx-target="closest" hx-swap="outerHTML" hx-swap="swap"></div>"#,
539                Point::new(0, 9),
540                Some(Position::AttributeName(String::from("hx-get"))),
541            ),
542            (
543                r#"<a hx-swap="" hx-patch="/route" hx-validate"#,
544                Point::new(0, 40),
545                Some(Position::AttributeName(String::from("hx-validate"))),
546            ),
547        ];
548
549        for case in cases {
550            let text = case.0;
551            let tree = prepare_tree(text);
552            let query = HTMLQueries::default();
553            let matches = query_position(tree.root_node(), text, case.1, QueryType::Hover, &query);
554            assert_eq!(matches, case.2);
555        }
556    }
557
558    #[test]
559    fn unfinished_tag_name() {
560        let cases = [(
561            r#"<a hx-swap class="text-2xl">
562       
563</a>
564                
565            "#,
566            Point::new(1, 5),
567            QueryType::Completion,
568        )];
569        for case in cases {
570            let text = case.0;
571            let tree = prepare_tree(text);
572            let query = HTMLQueries::default();
573            let matches = query_position(tree.root_node(), text, case.1, case.2, &query);
574            assert_eq!(matches, Some(Position::AttributeName(String::from("--"))));
575        }
576    }
577
578    #[test]
579    fn python_tags() {
580        let case = r#"
581def a():
582    # hx@hello
583    # hx@world
584    print("hello world")
585    # hx@hello_world
586        "#;
587        let tree = prepare_python_tree(case);
588        let trigger_point = Point::new(0, 0);
589        let closest_node = tree.root_node();
590        let mut query = Queries::default();
591        query.change_backend("python");
592        let query = &query.backend;
593        let props = query_props(closest_node, case, trigger_point, query, true);
594        assert_eq!(props.len(), 3);
595    }
596}