Skip to main content

steel_core/command/execution/block/
suggestions.rs

1//! Stateful completions for block states and block predicates.
2
3use steel_registry::{
4    REGISTRY, RegistryExt as _, TaggedRegistryExt as _,
5    blocks::{BlockRef, block_state_ext::BlockStateExt as _},
6};
7use steel_utils::Identifier;
8
9use super::super::argument::{matches_substring, parse_identifier};
10use crate::command::brigadier::{StringReader, SuggestionsBuilder};
11
12pub(in crate::command::execution) fn suggest_blocks(builder: &mut SuggestionsBuilder<'_>) {
13    suggest_blocks_and_tags(builder, true);
14}
15
16pub(in crate::command::execution) fn suggest_block_inputs(builder: &mut SuggestionsBuilder<'_>) {
17    suggest_blocks_and_tags(builder, false);
18}
19
20fn suggest_blocks_and_tags(builder: &mut SuggestionsBuilder<'_>, include_tags: bool) {
21    let input = builder.remaining().to_owned();
22    let Some(syntax_start) = input.find(['[', '{']) else {
23        if let Some(target) = SuggestedBlockTarget::parse(&input, include_tags) {
24            if target.has_properties() {
25                builder.suggest(format!("{input}["));
26            }
27            if target.has_block_entity() {
28                builder.suggest(format!("{input}{{"));
29            }
30        } else {
31            suggest_block_resources(&input, include_tags, builder);
32        }
33        return;
34    };
35
36    let resource = &input[..syntax_start];
37    let Some(target) = SuggestedBlockTarget::parse(resource, include_tags) else {
38        return;
39    };
40    match input.as_bytes()[syntax_start] {
41        b'[' => suggest_block_properties(&input, syntax_start, &target, builder),
42        b'{' => {}
43        _ => unreachable!("syntax search only returns block-state delimiters"),
44    }
45}
46
47fn suggest_block_resources(input: &str, include_tags: bool, builder: &mut SuggestionsBuilder<'_>) {
48    let lowercase = input.to_lowercase();
49    if let Some(prefix) = lowercase.strip_prefix('#') {
50        if include_tags {
51            for tag in REGISTRY
52                .blocks
53                .tag_keys()
54                .filter(|tag| identifier_matches(prefix, tag))
55            {
56                builder.suggest(format!("#{tag}"));
57            }
58        }
59        return;
60    }
61    for block in REGISTRY
62        .blocks
63        .iter()
64        .map(|(_, block)| &block.key)
65        .filter(|key| identifier_matches(&lowercase, key))
66    {
67        builder.suggest(block.to_string());
68    }
69    if include_tags {
70        for tag in REGISTRY
71            .blocks
72            .tag_keys()
73            .filter(|tag| identifier_matches(&lowercase, tag))
74        {
75            builder.suggest(format!("#{tag}"));
76        }
77    }
78}
79
80fn suggest_block_properties(
81    input: &str,
82    syntax_start: usize,
83    target: &SuggestedBlockTarget,
84    builder: &mut SuggestionsBuilder<'_>,
85) {
86    let body = &input[syntax_start + 1..];
87    if let Some(end) = find_unquoted(body, ']') {
88        if body[end + 1..].is_empty() && target.has_block_entity() {
89            builder.suggest(format!("{input}{{"));
90        }
91        return;
92    }
93
94    let current_start = current_property_start(body);
95    let current = &body[current_start..];
96    let leading_whitespace = current.len() - current.trim_start().len();
97    let prefix_end = syntax_start + 1 + current_start + leading_whitespace;
98    let prefix = &input[..prefix_end];
99    let current = &current[leading_whitespace..];
100    let visited = completed_property_names(&body[..current_start]);
101
102    let Some(equals) = find_unquoted(current, '=') else {
103        let key = parsed_command_string(current).unwrap_or_else(|| current.trim().to_owned());
104        if !visited.iter().any(|seen| seen == &key) && target.has_property(&key) {
105            builder.suggest(format!("{input}="));
106        } else if current.trim().len() == current.len() {
107            let lowercase = key.to_lowercase();
108            for property in target.property_names() {
109                if !visited.iter().any(|seen| seen == property) && property.starts_with(&lowercase)
110                {
111                    builder.suggest(format!("{prefix}{property}="));
112                }
113            }
114        }
115        if current_start == 0 && current.trim().is_empty() {
116            builder.suggest(format!("{prefix}]"));
117        }
118        return;
119    };
120
121    let Some(property) = parsed_command_string(&current[..equals]) else {
122        return;
123    };
124    if visited.iter().any(|seen| seen == &property)
125        || (!target.is_tag && !target.has_property(&property))
126    {
127        return;
128    }
129    let value_input = &current[equals + 1..];
130    let value_whitespace = value_input.len() - value_input.trim_start().len();
131    let value_prefix_end = prefix_end + equals + 1 + value_whitespace;
132    let value_prefix = &input[..value_prefix_end];
133    let value_input = &value_input[value_whitespace..];
134    let value = parsed_command_string(value_input).unwrap_or_else(|| value_input.trim().to_owned());
135    let value_is_exact = target.has_property_value(&property, &value);
136
137    if target.is_tag || !value_is_exact {
138        for possible in target.property_value_names(&property) {
139            builder.suggest(format!("{value_prefix}{possible}"));
140        }
141    }
142    if target.is_tag || value_is_exact {
143        let has_more = target
144            .property_names()
145            .into_iter()
146            .any(|name| name != property && !visited.iter().any(|seen| seen == name));
147        if has_more {
148            builder.suggest(format!("{input},"));
149        }
150        builder.suggest(format!("{input}]"));
151    }
152}
153
154struct SuggestedBlockTarget {
155    blocks: Vec<BlockRef>,
156    is_tag: bool,
157}
158
159impl SuggestedBlockTarget {
160    fn parse(input: &str, include_tags: bool) -> Option<Self> {
161        if let Some(tag) = input.strip_prefix('#') {
162            if !include_tags {
163                return None;
164            }
165            let key = parse_suggestion_identifier(tag)?;
166            return Some(Self {
167                blocks: REGISTRY.blocks.get_tag(&key)?,
168                is_tag: true,
169            });
170        }
171        let key = parse_suggestion_identifier(input)?;
172        Some(Self {
173            blocks: vec![REGISTRY.blocks.by_key(&key)?],
174            is_tag: false,
175        })
176    }
177
178    fn property_names(&self) -> Vec<&'static str> {
179        let mut names = Vec::new();
180        for block in &self.blocks {
181            for property in block.properties {
182                let name = property.get_name();
183                if !names.contains(&name) {
184                    names.push(name);
185                }
186            }
187        }
188        names
189    }
190
191    fn property_value_names(&self, name: &str) -> Vec<&str> {
192        let mut values = Vec::new();
193        for block in &self.blocks {
194            let Some(property) = block
195                .properties
196                .iter()
197                .find(|property| property.get_name() == name)
198            else {
199                continue;
200            };
201            for value in property.get_possible_value_names() {
202                if !values.contains(&value) {
203                    values.push(value);
204                }
205            }
206        }
207        values
208    }
209
210    fn has_properties(&self) -> bool {
211        self.blocks.iter().any(|block| !block.properties.is_empty())
212    }
213
214    fn has_property(&self, name: &str) -> bool {
215        self.blocks.iter().any(|block| {
216            block
217                .properties
218                .iter()
219                .any(|property| property.get_name() == name)
220        })
221    }
222
223    fn has_property_value(&self, name: &str, value: &str) -> bool {
224        self.blocks.iter().any(|block| {
225            block.properties.iter().any(|property| {
226                property.get_name() == name && property.get_possible_value_names().contains(&value)
227            })
228        })
229    }
230
231    fn has_block_entity(&self) -> bool {
232        self.blocks
233            .iter()
234            .any(|block| block.default_state().has_block_entity())
235    }
236}
237
238fn parse_suggestion_identifier(input: &str) -> Option<Identifier> {
239    let mut reader = StringReader::new(input);
240    let identifier = parse_identifier(&mut reader).ok()?;
241    (!reader.can_read()).then_some(identifier)
242}
243
244fn parsed_command_string(input: &str) -> Option<String> {
245    let mut reader = StringReader::new(input);
246    reader.skip_whitespace();
247    let value = reader.read_string().ok()?;
248    reader.skip_whitespace();
249    (!reader.can_read()).then_some(value)
250}
251
252fn completed_property_names(input: &str) -> Vec<String> {
253    let mut names = Vec::new();
254    let mut start = 0;
255    while let Some(separator) = find_unquoted(&input[start..], ',') {
256        let end = start + separator;
257        let property = &input[start..end];
258        if let Some(equals) = find_unquoted(property, '=')
259            && let Some(name) = parsed_command_string(&property[..equals])
260        {
261            names.push(name);
262        }
263        start = end + 1;
264    }
265    names
266}
267
268fn current_property_start(input: &str) -> usize {
269    let mut start = 0;
270    while let Some(separator) = find_unquoted(&input[start..], ',') {
271        start += separator + 1;
272    }
273    start
274}
275
276fn find_unquoted(input: &str, needle: char) -> Option<usize> {
277    let mut quote = None;
278    let mut escaped = false;
279    for (index, character) in input.char_indices() {
280        if let Some(terminator) = quote {
281            if escaped {
282                escaped = false;
283            } else if character == '\\' {
284                escaped = true;
285            } else if character == terminator {
286                quote = None;
287            }
288            continue;
289        }
290        match character {
291            '\'' | '"' => quote = Some(character),
292            _ if character == needle => return Some(index),
293            _ => {}
294        }
295    }
296    None
297}
298
299fn identifier_matches(pattern: &str, identifier: &Identifier) -> bool {
300    if pattern.contains(':') {
301        matches_substring(pattern, &identifier.to_string())
302    } else {
303        matches_substring(pattern, identifier.namespace.as_ref())
304            || matches_substring(pattern, identifier.path.as_ref())
305    }
306}