Skip to main content

maya_mel/sema/
command_norm.rs

1//! Advanced normalized command data structures.
2//!
3//! This module is mainly useful for tools that need to inspect command-mode
4//! decisions, flag normalization, or positional argument classification after
5//! semantic analysis.
6
7use 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}