Skip to content

Add validation phase to provide meaningful errors for invalid parameter values #1

@eferro

Description

@eferro

Problem

When a user provides a value that doesn't match a parameterized type (e.g., OptionsType), the command is not recognized at all, and the user sees a generic NoMatchingCommandFoundError ("Unknown command").

This happens because match() serves two purposes simultaneously:

  1. Command identification (routing) — determining which command the user intended
  2. Value validation — checking that parameter values are acceptable

Since OptionsType.match() returns False for values not in the valid list, the entire command fails to match. The interpreter treats it as if the command doesn't exist, rather than as a recognized command with an invalid parameter.

Example

Given a command registered as:

["environment", "maintenance", "on", OptionsType(["dev", "stg"])]

Input: /environment maintenance on production

Current behavior: NoMatchingCommandFoundError → "Unknown command"
Expected behavior: Command is recognized, but a clear validation error is returned → "Invalid option 'production'. Valid options: dev, stg"

This affects all parameterized types (OptionsType, IntegerType, RegexType, etc.), not just OptionsType.

Proposal: Separate matching from validation

Add a validation phase to the interpreter that runs after structural matching fails strict matching. This keeps backward compatibility while providing actionable error messages.

1. Add validate() to BaseType

class BaseType:
    # existing methods...
    
    def validate(self, word: str, context: Context, partial_line: list[str] | None = None) -> str | None:
        """Return None if valid, or an error message string if invalid."""
        return None  # default: always valid (backward compatible)

Each concrete type overrides with its own validation logic and message:

  • OptionsType.validate()"Invalid option 'foo'. Valid options: dev, stg"
  • IntegerType.validate()"Expected integer between 1 and 10, got 'abc'"
  • RegexType.validate()"Value 'xyz' doesn't match expected pattern: <regex>"
  • StringType.validate()None (any non-empty string is valid)

2. Add structural_match() to Command

A new method that matches only the command structure: keyword positions must match exactly, but parameterized positions accept any non-empty string.

def structural_match(self, tokens: list[str], context: Context) -> bool:
    if not self.context_match(context):
        return False
    if len(tokens) != len(self.keywords):
        return False
    for index, word in enumerate(tokens):
        definition = self.definitions[index]
        if isinstance(definition, KeywordType):
            if not definition.match(word, context, partial_line=tokens):
                return False
        else:
            if not word:  # parameterized position: accept any non-empty string
                return False
    return True

3. Add validate_parameters() to Command

Collects validation errors from all parameterized positions:

def validate_parameters(self, tokens: list[str], context: Context) -> list[str]:
    errors = []
    for index, word in enumerate(tokens):
        definition = self.definitions[index]
        if not isinstance(definition, KeywordType):
            error = definition.validate(word, context, partial_line=tokens)
            if error:
                errors.append(error)
    return errors

4. Add CommandValidationError exception

class CommandValidationError(EvalError):
    def __init__(self, command: Command, errors: list[str]) -> None:
        self.command = command
        self.errors = errors

5. Two-phase matching in Interpreter._matching_command()

def _matching_command(self, tokens: list[str], line_text: str) -> Command:
    # Phase 1: strict match (current behavior)
    matching = self._select_matching_commands(tokens)
    if len(matching) == 1:
        return matching[0]
    if len(matching) > 1:
        raise AmbiguousCommandError(matching)

    # Phase 2: structural match + validation
    structural = [cmd for cmd in self._commands 
                  if cmd.structural_match(tokens, self.actual_context())]
    if len(structural) == 1:
        errors = structural[0].validate_parameters(tokens, self.actual_context())
        if errors:
            raise CommandValidationError(structural[0], errors)
    
    # Phase 3: no match at all
    raise NoMatchingCommandFoundError(line_text)

Why this approach

  • Backward compatible: strict matching runs first; existing behavior is preserved for valid inputs
  • All types benefit: not limited to OptionsType — any parameterized type can provide meaningful validation messages
  • Clean separation: match() handles routing, validate() handles error reporting. Each type already knows its constraints (valid options, integer bounds, regex pattern), so the validation messages are natural
  • Autocompletion unaffected: complete() and partial_match() remain unchanged
  • Minimal surface area: one new method on BaseType, one new method on Command, one new exception, and a small change in the interpreter's matching flow

Alternatives considered

strict parameter on types

Adding strict=False to make match() permissive. Rejected because it pushes validation responsibility to the caller (who would need to re-validate), and doesn't provide a channel for error messages.

Lenient wrapper type

Lenient(OptionsType(["dev", "stg"])) — a decorator that makes match permissive. Rejected because it adds complexity at the registration site and still needs a validation mechanism to report errors.

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions