1use mel_ast::ShellWord;
8use mel_syntax::{SourceView, TextRange, range_end, range_start, text_range};
9use std::sync::Arc;
10
11use crate::{
12 CommandModeMask, CommandSchema, Diagnostic, DiagnosticSeverity, FlagArity, FlagArityByMode,
13 PositionalSchema, PositionalSourcePolicy, PositionalTailSchema, ScopeId,
14 ValidatedCommandSchema, ValueShape, command_schema::CommandKind,
15};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum CommandMode {
19 Create,
20 Edit,
21 Query,
22 Unknown,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct PositionalArg {
27 pub word: ShellWord,
28 pub range: TextRange,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct NormalizedFlag {
33 pub source_range: TextRange,
34 pub canonical_name: Option<Arc<str>>,
35 pub args: Vec<PositionalArg>,
36 pub range: TextRange,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum NormalizedCommandItem {
41 Flag(NormalizedFlag),
42 Positional(PositionalArg),
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct NormalizedCommandInvoke {
47 pub range: TextRange,
48 pub scope: ScopeId,
49 pub head_range: TextRange,
50 pub schema_name: Arc<str>,
51 pub kind: CommandKind,
52 pub mode: CommandMode,
53 pub items: Vec<NormalizedCommandItem>,
54}
55
56#[derive(Debug, Clone, Copy)]
57struct BorrowedPositionalArg<'a> {
58 word: &'a ShellWord,
59 range: TextRange,
60}
61
62#[derive(Debug, Clone, Copy)]
63struct SyntheticFlagSchema {
64 long_name: &'static str,
65 mode_mask: CommandModeMask,
66 arity_by_mode: FlagArityByMode,
67 allows_multiple: bool,
68}
69
70#[derive(Debug, Clone, Copy)]
71enum ResolvedFlagSchema<'a> {
72 Borrowed(&'a crate::FlagSchema),
73 Synthetic(SyntheticFlagSchema),
74}
75
76impl ResolvedFlagSchema<'_> {
77 fn long_name(&self) -> &str {
78 match self {
79 Self::Borrowed(schema) => &schema.long_name,
80 Self::Synthetic(schema) => schema.long_name,
81 }
82 }
83
84 fn canonical_name(&self) -> Arc<str> {
85 match self {
86 Self::Borrowed(schema) => schema.long_name.clone(),
87 Self::Synthetic(schema) => Arc::from(schema.long_name),
88 }
89 }
90
91 fn mode_mask(self) -> CommandModeMask {
92 match self {
93 Self::Borrowed(schema) => schema.mode_mask,
94 Self::Synthetic(schema) => schema.mode_mask,
95 }
96 }
97
98 fn arity_by_mode(self) -> FlagArityByMode {
99 match self {
100 Self::Borrowed(schema) => schema.arity_by_mode,
101 Self::Synthetic(schema) => schema.arity_by_mode,
102 }
103 }
104
105 fn allows_multiple(self) -> bool {
106 match self {
107 Self::Borrowed(schema) => schema.allows_multiple,
108 Self::Synthetic(schema) => schema.allows_multiple,
109 }
110 }
111}
112
113fn push_primary_diagnostic(
114 diagnostics: &mut Vec<Diagnostic>,
115 severity: DiagnosticSeverity,
116 range: TextRange,
117 message: impl Into<Arc<str>>,
118) {
119 push_primary_diagnostic_filtered(diagnostics, severity, range, message, true);
120}
121
122fn push_primary_diagnostic_filtered(
123 diagnostics: &mut Vec<Diagnostic>,
124 severity: DiagnosticSeverity,
125 range: TextRange,
126 message: impl Into<Arc<str>>,
127 include_warnings: bool,
128) {
129 if severity == DiagnosticSeverity::Warning && !include_warnings {
130 return;
131 }
132 let message = message.into();
133 diagnostics.push(Diagnostic {
134 severity,
135 message: message.clone(),
136 range,
137 labels: vec![crate::DiagnosticLabel {
138 range,
139 message,
140 is_primary: true,
141 }],
142 });
143}
144
145pub(crate) fn normalize_shell_like_invoke(
146 command: &ValidatedCommandSchema,
147 scope: ScopeId,
148 head_range: TextRange,
149 words: &[ShellWord],
150 range: TextRange,
151 source: SourceView<'_>,
152) -> (NormalizedCommandInvoke, Vec<Diagnostic>) {
153 let mut diagnostics = Vec::new();
154 let mut items = Vec::new();
155 let mut seen_flags = Vec::<ResolvedFlagSchema<'_>>::new();
156 let mut positional_args = Vec::<BorrowedPositionalArg<'_>>::new();
157 let (create_ranges, edit_ranges, query_ranges) =
158 collect_mode_flag_ranges(command, words, source);
159 let active_mode_count = usize::from(!create_ranges.is_empty())
160 + usize::from(!edit_ranges.is_empty())
161 + usize::from(!query_ranges.is_empty());
162 let mode = match active_mode_count {
163 0 => CommandMode::Create,
164 1 if !create_ranges.is_empty() => CommandMode::Create,
165 1 if !edit_ranges.is_empty() => CommandMode::Edit,
166 1 if !query_ranges.is_empty() => CommandMode::Query,
167 _ => {
168 diagnostics.push(Diagnostic {
169 severity: DiagnosticSeverity::Error,
170 message: format!(
171 "command \"{}\" cannot combine create/edit/query mode flags",
172 command.name
173 )
174 .into(),
175 range,
176 labels: vec![crate::DiagnosticLabel {
177 range,
178 message: format!(
179 "command \"{}\" cannot combine create/edit/query mode flags",
180 command.name
181 )
182 .into(),
183 is_primary: true,
184 }],
185 });
186 CommandMode::Unknown
187 }
188 };
189 let mut index = 0;
190
191 while index < words.len() {
192 match &words[index] {
193 ShellWord::Flag {
194 text,
195 range: flag_range,
196 } => {
197 let flag_text = source.slice(*text);
198 let Some(schema) = find_flag_schema(command, flag_text) else {
199 diagnostics.push(Diagnostic {
200 severity: DiagnosticSeverity::Warning,
201 message: format!(
202 "unknown flag \"{flag_text}\" for command \"{}\"",
203 command.name
204 )
205 .into(),
206 range: *flag_range,
207 labels: vec![crate::DiagnosticLabel {
208 range: *flag_range,
209 message: format!(
210 "unknown flag \"{flag_text}\" for command \"{}\"",
211 command.name
212 )
213 .into(),
214 is_primary: true,
215 }],
216 });
217 items.push(NormalizedCommandItem::Flag(NormalizedFlag {
218 source_range: *flag_range,
219 canonical_name: None,
220 args: Vec::new(),
221 range: *flag_range,
222 }));
223 index += 1;
224 continue;
225 };
226
227 if !schema.allows_multiple()
228 && seen_flags
229 .iter()
230 .any(|seen_schema| seen_schema.long_name() == schema.long_name())
231 {
232 diagnostics.push(Diagnostic {
233 severity: DiagnosticSeverity::Error,
234 message: format!(
235 "flag \"-{0}\" cannot be repeated for command \"{1}\"",
236 schema.long_name(),
237 command.name
238 )
239 .into(),
240 range: *flag_range,
241 labels: vec![crate::DiagnosticLabel {
242 range: *flag_range,
243 message: format!(
244 "flag \"-{0}\" cannot be repeated for command \"{1}\"",
245 schema.long_name(),
246 command.name
247 )
248 .into(),
249 is_primary: true,
250 }],
251 });
252 } else {
253 seen_flags.push(schema);
254 }
255
256 let expected_arity = arity_for_mode(schema.arity_by_mode(), mode);
257 let (min_arity, max_arity) = arity_bounds(expected_arity);
258 let mut args = Vec::new();
259 let mut consumed = 0;
260 while consumed < max_arity {
261 let next_index = index + 1 + consumed;
262 let Some(next_word) = words.get(next_index) else {
263 break;
264 };
265 if matches!(next_word, ShellWord::Flag { .. }) {
266 break;
267 }
268 args.push(BorrowedPositionalArg {
269 word: next_word,
270 range: shell_word_range(next_word),
271 });
272 consumed += 1;
273 }
274
275 let owned_args = args
276 .iter()
277 .map(|arg| PositionalArg {
278 word: arg.word.clone(),
279 range: arg.range,
280 })
281 .collect::<Vec<_>>();
282
283 if args.len() < min_arity {
284 diagnostics.push(Diagnostic {
285 severity: if matches!(mode, CommandMode::Query) {
286 DiagnosticSeverity::Warning
287 } else {
288 DiagnosticSeverity::Error
289 },
290 message: format!(
291 "flag \"-{0}\" expects {1} argument(s) for command \"{2}\"",
292 schema.long_name(),
293 format_arity(expected_arity),
294 command.name
295 )
296 .into(),
297 range: *flag_range,
298 labels: vec![crate::DiagnosticLabel {
299 range: *flag_range,
300 message: format!(
301 "flag \"-{0}\" expects {1} argument(s) for command \"{2}\"",
302 schema.long_name(),
303 format_arity(expected_arity),
304 command.name
305 )
306 .into(),
307 is_primary: true,
308 }],
309 });
310 }
311
312 let item_range = args.last().map_or(*flag_range, |arg| {
313 text_range(range_start(*flag_range), range_end(arg.range))
314 });
315 items.push(NormalizedCommandItem::Flag(NormalizedFlag {
316 source_range: *flag_range,
317 canonical_name: Some(schema.canonical_name()),
318 args: owned_args,
319 range: item_range,
320 }));
321 index += 1 + consumed;
322 }
323 word => {
324 let positional_arg = BorrowedPositionalArg {
325 word,
326 range: shell_word_range(word),
327 };
328 positional_args.push(positional_arg);
329 items.push(NormalizedCommandItem::Positional(PositionalArg {
330 word: word.clone(),
331 range: positional_arg.range,
332 }));
333 index += 1;
334 }
335 }
336 }
337
338 if !mode_allows(command.mode_mask, mode) {
339 diagnostics.push(Diagnostic {
340 severity: if matches!(mode, CommandMode::Query) {
341 DiagnosticSeverity::Warning
342 } else {
343 DiagnosticSeverity::Error
344 },
345 message: format!(
346 "command \"{}\" is not available in {} mode",
347 command.name,
348 mode_label(mode)
349 )
350 .into(),
351 range,
352 labels: vec![crate::DiagnosticLabel {
353 range,
354 message: format!(
355 "command \"{}\" is not available in {} mode",
356 command.name,
357 mode_label(mode)
358 )
359 .into(),
360 is_primary: true,
361 }],
362 });
363 }
364
365 for item in &items {
366 let NormalizedCommandItem::Flag(flag) = item else {
367 continue;
368 };
369 let Some(canonical_name) = flag.canonical_name.as_deref() else {
370 continue;
371 };
372 let Some(schema) = find_flag_schema_by_canonical_name(command, canonical_name) else {
373 continue;
374 };
375 if !mode_allows(schema.mode_mask(), mode) {
376 diagnostics.push(Diagnostic {
377 severity: if matches!(mode, CommandMode::Query) {
378 DiagnosticSeverity::Warning
379 } else {
380 DiagnosticSeverity::Error
381 },
382 message: format!(
383 "flag \"-{0}\" is not available in {1} mode for command \"{2}\"",
384 schema.long_name(),
385 mode_label(mode),
386 command.name
387 )
388 .into(),
389 range: flag.source_range,
390 labels: vec![crate::DiagnosticLabel {
391 range: flag.source_range,
392 message: format!(
393 "flag \"-{0}\" is not available in {1} mode for command \"{2}\"",
394 schema.long_name(),
395 mode_label(mode),
396 command.name
397 )
398 .into(),
399 is_primary: true,
400 }],
401 });
402 }
403 }
404
405 let positional_args = positional_args.iter().collect::<Vec<_>>();
406 validate_positionals(
407 command,
408 &command.positionals,
409 &positional_args,
410 range,
411 &mut diagnostics,
412 source,
413 );
414
415 (
416 NormalizedCommandInvoke {
417 range,
418 scope,
419 head_range,
420 schema_name: command.name.clone(),
421 kind: command.kind,
422 mode,
423 items,
424 },
425 diagnostics,
426 )
427}
428
429pub(crate) fn collect_command_diagnostics(
430 command: &CommandSchema,
431 words: &[ShellWord],
432 range: TextRange,
433 source: SourceView<'_>,
434 include_warnings: bool,
435) -> Vec<Diagnostic> {
436 let mut diagnostics = Vec::new();
437 let mut seen_flags = Vec::<ResolvedFlagSchema<'_>>::new();
438 let mut positional_args = Vec::<BorrowedPositionalArg<'_>>::new();
439 let mut seen_flag_instances = Vec::<(TextRange, Option<ResolvedFlagSchema<'_>>)>::new();
440 let (create_ranges, edit_ranges, query_ranges) =
441 collect_mode_flag_ranges(command, words, source);
442 let active_mode_count = usize::from(!create_ranges.is_empty())
443 + usize::from(!edit_ranges.is_empty())
444 + usize::from(!query_ranges.is_empty());
445 let mode = match active_mode_count {
446 0 => CommandMode::Create,
447 1 if !create_ranges.is_empty() => CommandMode::Create,
448 1 if !edit_ranges.is_empty() => CommandMode::Edit,
449 1 if !query_ranges.is_empty() => CommandMode::Query,
450 _ => {
451 push_primary_diagnostic(
452 &mut diagnostics,
453 DiagnosticSeverity::Error,
454 range,
455 format!(
456 "command \"{}\" cannot combine create/edit/query mode flags",
457 command.name
458 ),
459 );
460 CommandMode::Unknown
461 }
462 };
463 let mut index = 0;
464
465 while index < words.len() {
466 match &words[index] {
467 ShellWord::Flag {
468 text,
469 range: flag_range,
470 } => {
471 let flag_text = source.slice(*text);
472 let Some(schema) = find_flag_schema(command, flag_text) else {
473 push_primary_diagnostic_filtered(
474 &mut diagnostics,
475 DiagnosticSeverity::Warning,
476 *flag_range,
477 format!(
478 "unknown flag \"{flag_text}\" for command \"{}\"",
479 command.name
480 ),
481 include_warnings,
482 );
483 seen_flag_instances.push((*flag_range, None));
484 index += 1;
485 continue;
486 };
487
488 if !schema.allows_multiple()
489 && seen_flags
490 .iter()
491 .any(|seen_schema| seen_schema.long_name() == schema.long_name())
492 {
493 push_primary_diagnostic(
494 &mut diagnostics,
495 DiagnosticSeverity::Error,
496 *flag_range,
497 format!(
498 "flag \"-{0}\" cannot be repeated for command \"{1}\"",
499 schema.long_name(),
500 command.name
501 ),
502 );
503 } else {
504 seen_flags.push(schema);
505 }
506
507 let expected_arity = arity_for_mode(schema.arity_by_mode(), mode);
508 let (min_arity, max_arity) = arity_bounds(expected_arity);
509 let mut args = Vec::new();
510 let mut consumed = 0;
511 while consumed < max_arity {
512 let next_index = index + 1 + consumed;
513 let Some(next_word) = words.get(next_index) else {
514 break;
515 };
516 if matches!(next_word, ShellWord::Flag { .. }) {
517 break;
518 }
519 args.push(BorrowedPositionalArg {
520 word: next_word,
521 range: shell_word_range(next_word),
522 });
523 consumed += 1;
524 }
525
526 if args.len() < min_arity {
527 push_primary_diagnostic_filtered(
528 &mut diagnostics,
529 if matches!(mode, CommandMode::Query) {
530 DiagnosticSeverity::Warning
531 } else {
532 DiagnosticSeverity::Error
533 },
534 *flag_range,
535 format!(
536 "flag \"-{0}\" expects {1} argument(s) for command \"{2}\"",
537 schema.long_name(),
538 format_arity(expected_arity),
539 command.name
540 ),
541 include_warnings,
542 );
543 }
544 seen_flag_instances.push((*flag_range, Some(schema)));
545 index += 1 + consumed;
546 }
547 word => {
548 positional_args.push(BorrowedPositionalArg {
549 word,
550 range: shell_word_range(word),
551 });
552 index += 1;
553 }
554 }
555 }
556
557 if !mode_allows(command.mode_mask, mode) {
558 push_primary_diagnostic_filtered(
559 &mut diagnostics,
560 if matches!(mode, CommandMode::Query) {
561 DiagnosticSeverity::Warning
562 } else {
563 DiagnosticSeverity::Error
564 },
565 range,
566 format!(
567 "command \"{}\" is not available in {} mode",
568 command.name,
569 mode_label(mode)
570 ),
571 include_warnings,
572 );
573 }
574
575 for (flag_range, schema) in seen_flag_instances {
576 let Some(schema) = schema else {
577 continue;
578 };
579 if !mode_allows(schema.mode_mask(), mode) {
580 push_primary_diagnostic_filtered(
581 &mut diagnostics,
582 if matches!(mode, CommandMode::Query) {
583 DiagnosticSeverity::Warning
584 } else {
585 DiagnosticSeverity::Error
586 },
587 flag_range,
588 format!(
589 "flag \"-{0}\" is not available in {1} mode for command \"{2}\"",
590 schema.long_name(),
591 mode_label(mode),
592 command.name
593 ),
594 include_warnings,
595 );
596 }
597 }
598
599 let positional_args = positional_args.iter().collect::<Vec<_>>();
600 validate_positionals(
601 command,
602 &command.positionals,
603 &positional_args,
604 range,
605 &mut diagnostics,
606 source,
607 );
608
609 diagnostics
610}
611
612fn find_flag_schema<'a>(command: &'a CommandSchema, text: &str) -> Option<ResolvedFlagSchema<'a>> {
613 let normalized = text.strip_prefix('-').unwrap_or(text);
614 command
615 .flags
616 .iter()
617 .find(|flag| {
618 normalized == flag.long_name.as_ref()
619 || flag
620 .short_name
621 .as_deref()
622 .is_some_and(|short| short == normalized)
623 })
624 .map(ResolvedFlagSchema::Borrowed)
625 .or_else(|| synthetic_mode_flag_for_name(command, normalized))
626}
627
628fn find_flag_schema_by_canonical_name<'a>(
629 command: &'a CommandSchema,
630 canonical_name: &str,
631) -> Option<ResolvedFlagSchema<'a>> {
632 command
633 .flags
634 .iter()
635 .find(|flag| flag.long_name.as_ref() == canonical_name)
636 .map(ResolvedFlagSchema::Borrowed)
637 .or_else(|| synthetic_mode_flag_for_name(command, canonical_name))
638}
639
640fn synthetic_mode_flag_for_name(
641 command: &CommandSchema,
642 name: &str,
643) -> Option<ResolvedFlagSchema<'static>> {
644 match name {
645 "create" | "c" if command.mode_mask.create => Some(ResolvedFlagSchema::Synthetic(
646 synthetic_mode_flag("create", "c"),
647 )),
648 "edit" | "e" if command.mode_mask.edit => Some(ResolvedFlagSchema::Synthetic(
649 synthetic_mode_flag("edit", "e"),
650 )),
651 "query" | "q" if command.mode_mask.query => Some(ResolvedFlagSchema::Synthetic(
652 synthetic_mode_flag("query", "q"),
653 )),
654 _ => None,
655 }
656}
657
658fn synthetic_mode_flag(long_name: &'static str, short_name: &'static str) -> SyntheticFlagSchema {
659 let _ = short_name;
660 SyntheticFlagSchema {
661 long_name,
662 mode_mask: CommandModeMask {
663 create: true,
664 edit: true,
665 query: true,
666 },
667 arity_by_mode: FlagArityByMode {
668 create: FlagArity::None,
669 edit: FlagArity::None,
670 query: FlagArity::None,
671 },
672 allows_multiple: false,
673 }
674}
675
676fn collect_mode_flag_ranges(
677 command: &CommandSchema,
678 words: &[ShellWord],
679 source: SourceView<'_>,
680) -> (Vec<TextRange>, Vec<TextRange>, Vec<TextRange>) {
681 let mut create_ranges = Vec::new();
682 let mut edit_ranges = Vec::new();
683 let mut query_ranges = Vec::new();
684
685 for word in words {
686 let ShellWord::Flag { text, range } = word else {
687 continue;
688 };
689 let Some(schema) = find_flag_schema(command, source.slice(*text)) else {
690 continue;
691 };
692 match schema.long_name() {
693 "create" => create_ranges.push(*range),
694 "edit" => edit_ranges.push(*range),
695 "query" => query_ranges.push(*range),
696 _ => {}
697 }
698 }
699
700 (create_ranges, edit_ranges, query_ranges)
701}
702
703fn arity_for_mode(arity_by_mode: FlagArityByMode, mode: CommandMode) -> FlagArity {
704 match mode {
705 CommandMode::Create | CommandMode::Unknown => arity_by_mode.create,
706 CommandMode::Edit => arity_by_mode.edit,
707 CommandMode::Query => arity_by_mode.query,
708 }
709}
710
711fn arity_bounds(arity: FlagArity) -> (usize, usize) {
712 match arity {
713 FlagArity::None => (0, 0),
714 FlagArity::Exact(value) => {
715 let value = usize::from(value);
716 (value, value)
717 }
718 FlagArity::Range { min, max } => (usize::from(min), usize::from(max)),
719 }
720}
721
722fn format_arity(arity: FlagArity) -> String {
723 match arity {
724 FlagArity::None => "0".to_owned(),
725 FlagArity::Exact(value) => value.to_string(),
726 FlagArity::Range { min, max } if min == max => min.to_string(),
727 FlagArity::Range { min, max } => format!("{min} to {max}"),
728 }
729}
730
731fn validate_positionals(
732 command: &CommandSchema,
733 schema: &PositionalSchema,
734 positional_args: &[&BorrowedPositionalArg<'_>],
735 command_range: TextRange,
736 diagnostics: &mut Vec<Diagnostic>,
737 source: SourceView<'_>,
738) {
739 let prefix_len = schema.prefix.len();
740 let required_prefix_len = required_prefix_len(schema.prefix);
741 let positional_len = positional_args.len();
742
743 if prefix_len == 0 && matches!(schema.tail, PositionalTailSchema::None) && positional_len > 0 {
744 push_primary_diagnostic(
745 diagnostics,
746 DiagnosticSeverity::Error,
747 command_range,
748 format!(
749 "command \"{}\" does not accept positional arguments",
750 command.name
751 ),
752 );
753 return;
754 }
755
756 if positional_len < required_prefix_len {
757 push_primary_diagnostic(
758 diagnostics,
759 DiagnosticSeverity::Error,
760 command_range,
761 format!(
762 "command \"{}\" expects {} positional argument(s) but call provides {}",
763 command.name, required_prefix_len, positional_len
764 ),
765 );
766 return;
767 }
768
769 for (index, slot) in schema.prefix.iter().enumerate() {
770 if let Some(actual_shape) = positional_args
771 .get(index)
772 .and_then(|arg| inferred_value_shape(arg.word, source))
773 {
774 validate_positional_shape(
775 command,
776 index,
777 positional_args[index].range,
778 actual_shape,
779 slot.value_shapes,
780 diagnostics,
781 );
782 }
783 }
784
785 let tail_args = &positional_args[prefix_len.min(positional_len)..];
786 match schema.tail {
787 PositionalTailSchema::None => {
788 if !tail_args.is_empty() {
789 push_primary_diagnostic(
790 diagnostics,
791 DiagnosticSeverity::Error,
792 command_range,
793 format!(
794 "command \"{}\" expects {} positional argument(s) but call provides {}",
795 command.name, prefix_len, positional_len
796 ),
797 );
798 }
799 }
800 PositionalTailSchema::Opaque { min, max } => {
801 validate_tail_arity(
802 command,
803 min,
804 max,
805 tail_args.len(),
806 prefix_len,
807 command_range,
808 diagnostics,
809 );
810 }
811 PositionalTailSchema::Shaped {
812 min,
813 max,
814 value_shapes,
815 } => {
816 validate_tail_arity(
817 command,
818 min,
819 max,
820 tail_args.len(),
821 prefix_len,
822 command_range,
823 diagnostics,
824 );
825 for (tail_index, arg) in tail_args.iter().enumerate() {
826 let Some(actual_shape) = inferred_value_shape(arg.word, source) else {
827 continue;
828 };
829 validate_positional_shape(
830 command,
831 prefix_len + tail_index,
832 arg.range,
833 actual_shape,
834 value_shapes,
835 diagnostics,
836 );
837 }
838 }
839 }
840}
841
842fn required_prefix_len(prefix: &[crate::PositionalSlotSchema]) -> usize {
843 let mut seen_optional = false;
844 let mut required = 0;
845 for slot in prefix {
846 let is_optional = matches!(
847 slot.source_policy,
848 PositionalSourcePolicy::ExplicitOrCurrentSelection
849 );
850 if is_optional {
851 seen_optional = true;
852 } else if !seen_optional {
853 required += 1;
854 } else {
855 debug_assert!(
856 false,
857 "validated command schema invariant violated: selection-aware positional slots must form a trailing suffix"
858 );
859 }
860 }
861 required
862}
863
864fn validate_tail_arity(
865 command: &CommandSchema,
866 min: u8,
867 max: Option<u8>,
868 actual_tail_len: usize,
869 prefix_len: usize,
870 command_range: TextRange,
871 diagnostics: &mut Vec<Diagnostic>,
872) {
873 let min = usize::from(min);
874 let max = max.map(usize::from);
875 let actual_total = prefix_len + actual_tail_len;
876 let min_total = prefix_len + min;
877 let max_total = max.map(|max| prefix_len + max);
878 let too_few = actual_tail_len < min;
879 let too_many = max.is_some_and(|max| actual_tail_len > max);
880 if !too_few && !too_many {
881 return;
882 }
883
884 let expected = match max_total {
885 Some(max_total) if min_total == max_total => min_total.to_string(),
886 Some(max_total) => format!("{min_total} to {max_total}"),
887 None => format!("{min_total}+"),
888 };
889 let message = format!(
890 "command \"{}\" expects {expected} positional argument(s) but call provides {actual_total}",
891 command.name
892 );
893 let message: std::sync::Arc<str> = message.into();
894 diagnostics.push(Diagnostic {
895 severity: DiagnosticSeverity::Error,
896 message: message.clone(),
897 range: command_range,
898 labels: vec![crate::DiagnosticLabel {
899 range: command_range,
900 message,
901 is_primary: true,
902 }],
903 });
904}
905
906fn validate_positional_shape(
907 command: &CommandSchema,
908 index: usize,
909 arg_range: TextRange,
910 actual_shape: ValueShape,
911 allowed_shapes: &[ValueShape],
912 diagnostics: &mut Vec<Diagnostic>,
913) {
914 if allowed_shapes.is_empty()
915 || allowed_shapes
916 .iter()
917 .any(|shape| value_shape_matches(*shape, actual_shape))
918 {
919 return;
920 }
921
922 let expected = format_value_shapes(allowed_shapes);
923 let actual = format_value_shape(actual_shape);
924 let message = format!(
925 "positional argument {} for command \"{}\" expects {} but got {}",
926 index + 1,
927 command.name,
928 expected,
929 actual
930 );
931 let message: std::sync::Arc<str> = message.into();
932 diagnostics.push(Diagnostic {
933 severity: DiagnosticSeverity::Error,
934 message: message.clone(),
935 range: arg_range,
936 labels: vec![crate::DiagnosticLabel {
937 range: arg_range,
938 message,
939 is_primary: true,
940 }],
941 });
942}
943
944fn inferred_value_shape(word: &ShellWord, source: SourceView<'_>) -> Option<ValueShape> {
945 match word {
946 ShellWord::NumericLiteral { text, .. } => {
947 let text = source.slice(*text);
948 if text.contains('.') || text.contains('e') || text.contains('E') {
949 Some(ValueShape::Float)
950 } else {
951 Some(ValueShape::Int)
952 }
953 }
954 ShellWord::QuotedString { .. } => Some(ValueShape::String),
955 ShellWord::BareWord { text, .. } => {
956 let text = source.slice(*text);
957 match text {
958 "true" | "false" | "on" | "off" | "yes" | "no" => Some(ValueShape::Bool),
959 _ => None,
960 }
961 }
962 ShellWord::VectorLiteral { .. } => Some(ValueShape::FloatTuple(3)),
963 ShellWord::Flag { .. }
964 | ShellWord::Variable { .. }
965 | ShellWord::GroupedExpr { .. }
966 | ShellWord::BraceList { .. }
967 | ShellWord::Capture { .. } => None,
968 }
969}
970
971fn value_shape_matches(expected: ValueShape, actual: ValueShape) -> bool {
972 matches!(expected, ValueShape::Unknown | ValueShape::Script) || expected == actual
973}
974
975fn format_value_shapes(shapes: &[ValueShape]) -> String {
976 shapes
977 .iter()
978 .map(|shape| format_value_shape(*shape))
979 .collect::<Vec<_>>()
980 .join(" or ")
981}
982
983fn format_value_shape(shape: ValueShape) -> String {
984 match shape {
985 ValueShape::Bool => "bool".to_owned(),
986 ValueShape::Int => "int".to_owned(),
987 ValueShape::Float => "float".to_owned(),
988 ValueShape::String => "string".to_owned(),
989 ValueShape::Script => "script".to_owned(),
990 ValueShape::StringArray => "string[]".to_owned(),
991 ValueShape::FloatTuple(size) => format!("float[{size}]"),
992 ValueShape::IntTuple(size) => format!("int[{size}]"),
993 ValueShape::NodeName => "node name".to_owned(),
994 ValueShape::Unknown => "unknown".to_owned(),
995 }
996}
997
998fn shell_word_range(word: &ShellWord) -> TextRange {
999 match word {
1000 ShellWord::Flag { range, .. }
1001 | ShellWord::NumericLiteral { range, .. }
1002 | ShellWord::BareWord { range, .. }
1003 | ShellWord::QuotedString { range, .. }
1004 | ShellWord::Variable { range, .. }
1005 | ShellWord::GroupedExpr { range, .. }
1006 | ShellWord::BraceList { range, .. }
1007 | ShellWord::VectorLiteral { range, .. }
1008 | ShellWord::Capture { range, .. } => *range,
1009 }
1010}
1011
1012fn mode_allows(mask: CommandModeMask, mode: CommandMode) -> bool {
1013 match mode {
1014 CommandMode::Create => mask.create,
1015 CommandMode::Edit => mask.edit,
1016 CommandMode::Query => mask.query,
1017 CommandMode::Unknown => true,
1018 }
1019}
1020
1021fn mode_label(mode: CommandMode) -> &'static str {
1022 match mode {
1023 CommandMode::Create => "create",
1024 CommandMode::Edit => "edit",
1025 CommandMode::Query => "query",
1026 CommandMode::Unknown => "unknown",
1027 }
1028}