Paul King created GROOVY-12462:
----------------------------------

             Summary: Typed binding: let statically compiled REPL snippets use 
session variables with checked types
                 Key: GROOVY-12462
                 URL: https://issues.apache.org/jira/browse/GROOVY-12462
             Project: Groovy
          Issue Type: Improvement
            Reporter: Paul King


h2. Problem

REPL-style hosts compile each input as its own script and keep session state in 
a {{Binding}}. Examples are groovysh, notebook kernels such as groovy-jupyter, 
and embedders using {{GroovyShell}}. That works only with dynamic compilation. 
Once a host adds {{@CompileStatic}} or {{@TypeChecked}}, for example through an 
{{ASTTransformationCustomizer}}, every read of a session variable is an error:

{code:groovy}
// binding contains x: 42
x + 1
// [Static type checking] - The variable [x] is undeclared.
{code}

The only remaining route is {{binding.getVariable('x')}}, which returns 
{{Object}} and gives up the checking the host wanted.

Hosts have reasons to want static compilation:
* Earlier and clearer errors, with type-aware completion.
* The groovy-typecheckers checkers, such as NullChecker and PurityChecker, need 
resolved types.
* The proposed restricted compiler profile (GEP-32) forces static compilation, 
so that its effect checks can see every call target. Cells or snippets written 
by an agent are an expected use.

groovysh has a partial answer today. Interpreter mode keeps typed local 
variables by replaying their declarations in front of each new input, but 
untyped shared variables stay dynamic. groovy-jupyter lifts top-level 
declarations into binding assignments and drops the declared type.

h2. Proposal

A small, opt-in piece of core that REPL hosts add to their 
{{CompilerConfiguration}}. It keeps a session map from variable name to 
{{ClassNode}}, generics included, and has three parts.

# *Resolve.* A type-checking extension handles {{unresolvedVariable}} by 
looking the name up in the session map, so {{x + 1}} type-checks against the 
recorded type.
# *Export.* A CONVERSION-phase customizer keeps top-level declarations in the 
snippet ({{def n = 5}}, {{List<String> names = ...}}) as ordinary locals while 
compiling, so flow typing applies. It then appends a 
{{binding.setVariable(name, value)}} for each one at the end of the script and 
keeps the script's result value.
# *Record.* A customizer that runs after type checking, at CLASS_GENERATION, 
reads each exported declaration's inferred type 
({{StaticTypesMarker.INFERRED_TYPE}}) into the session map, so later snippets 
see it.

Hosts can also add types for values they put in the binding themselves. Values 
with no recorded type fall back to their runtime class, which has no generics, 
or are left unresolved, depending on configuration.

h3. Prototype results (Groovy 6.0.0)

A prototype of the three parts, using a script extension and a static holder 
for the map, gave these results across successive snippets in one 
{{GroovyShell}}:

||Snippet||Result||
|{{def names = \["a", "b"\]; names*.toUpperCase()}}|\[A, B\]; recorded 
{{ArrayList<String>}}|
|{{def n = 5; n += 1; n}}|6; recorded {{int}}|
|{{def m = \[a: 1\]; m.a + 1}}|2; recorded {{LinkedHashMap<String, Integer>}}|
|{{n + names.size()}}|8, compiled statically against the recorded types|
|{{names.first().foo()}}|refused: no {{String#foo()}}|
|{{names = new LinkedList<String>()}}|refused: cannot assign 
{{LinkedList<String>}} to {{ArrayList<String>}}|
|{{def names = new LinkedList<String>(); names.size()}}|0; the redeclaration 
replaces the recorded type|

The resolve step on its own, with types taken from runtime classes, also works 
({{x + 1}} gives 43), but it loses generics. With a raw {{List}}, 
{{names*.toUpperCase()}} is refused with {{E#toUpperCase()}}. That is why the 
record step reads inferred types from the compiler instead.

h3. Sketch of host usage

{code:groovy}
def session = new TypedBinding()                 // name and package to be 
decided
def config = new CompilerConfiguration()
session.applyTo(config)                          // adds the resolve extension 
and the export and record customizers
def shell = new GroovyShell(binding, config)     // the host keeps its own 
Binding
session.declare('spark', SparkSession)           // optional: types for values 
the host injects
{code}

The real implementation should be a precompiled extension class, not a script 
extension. It should get the session map through the compilation, for example 
as node metadata, not through a static field, so that concurrent sessions don't 
interfere.

h2. Design questions

* *Inferred or declared type.* The prototype records the narrow inferred type 
({{ArrayList}}, not {{List}}), so reassigning a different list type is refused. 
Options: record the declared type when one is written ({{List<String> names = 
...}}) and the inferred type for {{def}} and {{var}}; or widen inferred 
collection types to their interfaces. The first matches what the user wrote and 
seems the better default.
* *Redeclaration.* Redeclaring a name replaces its type, as groovysh already 
documents for definitions. Reassignment without {{def}} keeps the recorded type 
and is checked against it.
* *Exceptions and early return.* Exports run at the end of the snippet, so a 
snippet that throws or returns early does not export the variables it declared. 
Dynamic lifting would have kept them. Exporting in a {{finally}} block needs 
the declarations hoisted above the {{try}}, and should be decided explicitly.
* *Undeclared assignment.* In dynamic scripts, {{z = 5}} with no {{def}} 
creates a binding variable. Under static compilation it stays an error, unless 
the host opts in to treating it as a declaration.
* *Synthetic names.* Names the transformation introduces itself must not leak 
into the session map.
* *Where it lives.* Core ({{org.codehaus.groovy.control.customizers}}) is 
preferred over groovysh or groovy-typecheckers, so that groovysh, notebook 
kernels and embedders share one implementation.

h2. Who would use it

* *groovysh:* an optional typed or static mode for shared variables, alongside 
interpreter mode.
* *Notebook kernels (groovy-jupyter):* statically compiled cells, and a guarded 
mode for cells written by an agent.
* *Embedders, MCP servers that run snippets, and agent harnesses:* snippets 
with session state under {{@CompileStatic}}, including under the GEP-32 
restricted profile.

The proposal doesn't depend on GEP-32 and is useful without it.

h2. Tests

* Each row of the prototype table, as successive evaluations in one 
{{GroovyShell}}.
* Generic types survive across snippets (lists, maps, nested generics).
* Declared type versus {{def}}/{{var}}, according to the chosen rule.
* Redeclaration replaces the type; incompatible reassignment is refused.
* The behaviour on exceptions and early return, according to the chosen rule.
* Two sessions in parallel do not see each other's types.
* Dynamic compilation is unaffected when the feature is not enabled.




--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to