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)