Skip to content

[Java] @CopilotTool ergonomics: Validate @Param defaultValue parseability at compile time #1789

Description

@edburns

Overview

Add compile-time validation in CopilotToolProcessor that checks whether a @Param(defaultValue="...") string is parseable for the annotated parameter's type. Currently, an invalid default (e.g., @Param(defaultValue="abc") on an int parameter) will surface as either a compiler error in the generated $$CopilotToolMeta code or a runtime coercion error — and the error message will point at the generated class instead of the original annotation site.

This enhancement reports the error directly on the @Param annotation via Messager.printMessage(ERROR, ...), giving developers a clear message at the source of the problem.

Branch: edburns/1682-java-tool-ergonomics on upstream (⚠️ NOT main — PRs must target this branch)

Origin: PR #1787 review comment r3469523818

Prerequisites

• Tasks 4.1–4.4 must be complete and merged to the branch (they are).
• Before writing any code, read the existing validation logic in CopilotToolProcessor.java (lines 65–73 for the required=true + defaultValue conflict check).

Deliverables

File to modify

  1. java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java — add a validateDefaultValue(VariableElement param, TypeMirror type, String defaultValue) method and call it from the existing @Param validation loop.

Implementation specification

Add validation in the existing @Param validation loop (currently at lines 65–73) that checks parseability when defaultValue is non-empty. The validation must cover:

Parameter type Validation
int / java.lang.Integer Integer.parseInt(defaultValue) succeeds
long / java.lang.Long Long.parseLong(defaultValue) succeeds
short / java.lang.Short Short.parseShort(defaultValue) succeeds
byte / java.lang.Byte Byte.parseByte(defaultValue) succeeds
float / java.lang.Float Float.parseFloat(defaultValue) succeeds
double / java.lang.Double Double.parseDouble(defaultValue) succeeds
boolean / java.lang.Boolean Value is exactly "true" or "false" (case-insensitive)
char / java.lang.Character defaultValue.length() == 1
java.lang.String Always valid — skip validation
Enum types Value matches one of the enum constants (use TypeElement.getEnclosedElements() filtered by ElementKind.ENUM_CONSTANT)

For types not in this table (records, POJOs, java.time.*, collections, etc.), do NOT validate — emit no error. These types use Jackson convertValue() at runtime and their string representation is too complex for compile-time validation.

Error message format

@Param defaultValue "<value>" is not a valid <type>. Expected: <hint>.

Examples:

  • @Param defaultValue "abc" is not a valid int. Expected: an integer literal (e.g., "42").
  • @Param defaultValue "maybe" is not a valid boolean. Expected: "true" or "false".
  • @Param defaultValue "UNKNOWN" is not a valid com.example.Color. Expected one of: RED, GREEN, BLUE.

Where to integrate

In the existing validation loop in process():

// Validate @Param conflicts
for (VariableElement param : method.getParameters()) {
    Param paramAnnotation = param.getAnnotation(Param.class);
    if (paramAnnotation != null && paramAnnotation.required()
            && !paramAnnotation.defaultValue().isEmpty()) {
        processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR,
                "@Param cannot have both required=true and a non-empty defaultValue", param);
    }
    // ADD HERE: validate defaultValue parseability
    if (paramAnnotation != null && !paramAnnotation.defaultValue().isEmpty()) {
        validateDefaultValue(param, param.asType(), paramAnnotation.defaultValue());
    }
}

Helper method skeleton

/**
 * Validates that a @Param defaultValue string can be parsed as the given parameter type.
 * Emits a compile error on the parameter if validation fails.
 * Types that cannot be validated at compile time (records, POJOs, java.time.*, etc.)
 * are silently skipped.
 */
private void validateDefaultValue(VariableElement param, TypeMirror type, String defaultValue) {
    // Handle primitives
    if (type.getKind().isPrimitive()) {
        switch (type.getKind()) {
            case INT:
                tryParse(() -> Integer.parseInt(defaultValue), param, "int", "an integer literal (e.g., \"42\")");
                break;
            case LONG:
                tryParse(() -> Long.parseLong(defaultValue), param, "long", "a long literal (e.g., \"100\")");
                break;
            // ... short, byte, float, double, boolean, char
        }
        return;
    }
    // Handle declared types (boxed, String, enums)
    if (type.getKind() == TypeKind.DECLARED) {
        TypeElement typeElement = (TypeElement) ((DeclaredType) type).asElement();
        String qualifiedName = typeElement.getQualifiedName().toString();
        if ("java.lang.String".equals(qualifiedName)) {
            return; // always valid
        }
        if ("java.lang.Integer".equals(qualifiedName)) {
            tryParse(() -> Integer.parseInt(defaultValue), param, "Integer", "an integer literal");
            return;
        }
        // ... other boxed types, Boolean, Character
        if (typeElement.getKind() == ElementKind.ENUM) {
            validateEnumDefault(param, typeElement, defaultValue);
            return;
        }
    }
    // Other types: skip validation (too complex for compile time)
}

private void tryParse(Runnable parser, VariableElement param, String typeName, String hint) {
    try {
        parser.run();
    } catch (NumberFormatException e) {
        processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR,
                "@Param defaultValue \"" + /* defaultValue */ + "\" is not a valid " + typeName
                + ". Expected: " + hint + ".", param);
    }
}

private void validateEnumDefault(VariableElement param, TypeElement enumType, String defaultValue) {
    List<String> constants = enumType.getEnclosedElements().stream()
            .filter(e -> e.getKind() == ElementKind.ENUM_CONSTANT)
            .map(e -> e.getSimpleName().toString())
            .collect(Collectors.toList());
    if (!constants.contains(defaultValue)) {
        processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR,
                "@Param defaultValue \"" + defaultValue + "\" is not a valid "
                + enumType.getQualifiedName() + ". Expected one of: "
                + String.join(", ", constants) + ".", param);
    }
}

Gating tests and criteria

All of the following must pass before this task is considered complete:

  1. Invalid int default test: Compile a class with @Param(defaultValue="abc") int count — verify compile ERROR with message containing "not a valid int".
  2. Invalid long default test: @Param(defaultValue="not_a_long") long id — verify compile ERROR.
  3. Invalid boolean default test: @Param(defaultValue="maybe") boolean flag — verify compile ERROR with message mentioning "true" or "false".
  4. Invalid char default test: @Param(defaultValue="ab") char c — verify compile ERROR (length != 1).
  5. Invalid enum default test: @Param(defaultValue="UNKNOWN") MyEnum e where MyEnum has {RED, GREEN, BLUE} — verify compile ERROR listing the valid constants.
  6. Valid int default test: @Param(defaultValue="42") int count — verify NO compile error (compilation succeeds).
  7. Valid boolean default test: @Param(defaultValue="true") boolean flag — verify NO compile error.
  8. Valid enum default test: @Param(defaultValue="RED") MyEnum e — verify NO compile error.
  9. Skipped complex type test: @Param(defaultValue="2024-01-01T00:00:00Z") java.time.OffsetDateTime dt — verify NO compile error (validation is skipped for non-primitive, non-boxed, non-enum types).
  10. Valid String default test: @Param(defaultValue="anything") String s — verify NO compile error.
  11. Boxed types test: @Param(defaultValue="xyz") Integer n — verify compile ERROR.
  12. Existing required+defaultValue conflict still works: Ensure the existing required=true + defaultValue validation is unchanged and still reports its own error.
  13. Spotless format check: mvn spotless:check passes.
  14. Full test suite: mvn clean verify passes (existing tests not broken).

Constraints

• ✅✅ YOU MUST run mvn spotless:apply before every commit.
• Do NOT modify any files outside the java/ directory.
• Follow existing code style (4-space indent, Javadoc on public APIs).
• Add tests to the existing CopilotToolProcessorTest.java test class.
• The isBoxedNumeric() helper already exists in CopilotToolProcessor — reuse it for the boxed numeric type checks.
• The validation must NOT reject complex types it cannot validate — silently skip them.
• Boolean validation must be case-insensitive ("True", "FALSE", "true" are all valid).

Metadata

Metadata

Assignees

Type

No fields configured for Task.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions