Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,15 @@ QJSRuntime during: [ :context |
context eval: 'grid.name() + " has " + grid.rowAt_(1)' ]
```

Only the selectors you list exist in JS, named PharoJS-style: `#rowAt:` becomes `rowAt_`, and `#at:put:` becomes `at_put_`. To decide what JS may do with objects that come back from those calls, give a table of classes instead:
Only the selectors you list exist in JS. Their names in JS are a separate matter: a method can name itself with a pragma,

```smalltalk
at: key put: value
<jsName: 'atPut'>
...
```

so JS calls `grid.atPut(key, value)`. Without a pragma the name is PharoJS-style: `#rowAt:` becomes `rowAt_`, and `#at:put:` becomes `at_put_`. The pragma names the method, it does not expose it; two exposed selectors with the same name in one binding signal a `QJSError`. To decide what JS may do with objects that come back from those calls, give a table of classes instead:

```smalltalk
context at: 'grid' put: aGrid exposing: {
Expand Down
52 changes: 48 additions & 4 deletions src/QuickJS-Core/QJSBinding.class.st
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
"
I am what a runtime keeps for a Pharo object it has handed to JS: the object, the selectors JS may call on it, and the exposure table that decides the selectors of every object coming back from those calls.
I am what a runtime keeps for a Pharo object it has handed to JS: the object, the selectors JS may call on it, their names in JS, and the exposure table that decides the selectors of every object coming back from those calls.

The exposure table maps classes to selectors. An object gets the selectors of the nearest class in its hierarchy that has an entry; without an entry it gets none and stays opaque to JS.
The exposure table maps classes to selectors. An object gets the selectors of the nearest class in its hierarchy that has an entry; without an entry it gets none and stays opaque to JS. That is the permission.

The name is separate from the permission: a method names itself with a pragma, e.g. `at: key put: value <jsName: 'atPut'>`; without one, the name is PharoJS-style (#at:put: becomes at_put_). Two exposed selectors with the same name signal a QJSError.
"
Class {
#name : 'QJSBinding',
#superclass : 'Object',
#instVars : [
'object',
'exposure',
'selectors'
'selectors',
'names'
],
#category : 'QuickJS-Core',
#package : 'QuickJS-Core'
Expand All @@ -21,6 +24,13 @@ QJSBinding class >> object: anObject exposure: aDictionary [
^ self new setObject: anObject exposure: aDictionary
]

{ #category : 'naming' }
QJSBinding class >> pharoJSNameOf: aSelector [
"#at:put: becomes at_put_"

^ aSelector copyReplaceAll: ':' with: '_'
]

{ #category : 'testing' }
QJSBinding >> allows: aSelector [

Expand All @@ -33,6 +43,27 @@ QJSBinding >> exposure [
^ exposure
]

{ #category : 'naming' }
QJSBinding >> jsNameOf: aSelector [
"The name the method the object runs for aSelector gives itself with <jsName:>, otherwise the PharoJS-style name"

| name |
(object class lookupSelector: aSelector) ifNotNil: [ :method |
(method pragmaAt: #jsName:) ifNotNil: [ :pragma |
name := pragma arguments first.
(name isString and: [ name notEmpty ]) ifFalse: [
QJSError signal: '<jsName:> of ' , method printString , ' must be a non-empty string' ].
^ name asString ] ].
^ self class pharoJSNameOf: aSelector
]

{ #category : 'accessing' }
QJSBinding >> names [
"JS names to the selectors they call"

^ names
]

{ #category : 'accessing' }
QJSBinding >> object [

Expand All @@ -45,6 +76,18 @@ QJSBinding >> selectors [
^ selectors
]

{ #category : 'initialization' }
QJSBinding >> setNames [

names := Dictionary new.
selectors do: [ :each |
| name |
name := self jsNameOf: each.
names at: name ifPresent: [ :other |
QJSError signal: 'JS name ' , name , ' is used by both #' , other , ' and #' , each ].
names at: name put: each ]
]

{ #category : 'initialization' }
QJSBinding >> setObject: anObject exposure: aDictionary [

Expand All @@ -53,5 +96,6 @@ QJSBinding >> setObject: anObject exposure: aDictionary [
selectors := anObject class withAllSuperclasses
detect: [ :each | aDictionary includesKey: each ]
ifFound: [ :each | aDictionary at: each ]
ifNone: [ #( ) ]
ifNone: [ #( ) ].
self setNames
]
13 changes: 3 additions & 10 deletions src/QuickJS-Core/QJSContext.class.st
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"
I am a QuickJS context: a global object in a runtime. #eval: runs a script and answers its result as a Pharo value; a JS exception is signalled as a QJSError.

Values cross in both directions the same way: nil and null (undefined becomes nil too), Booleans, numbers as Integer or Float, strings, sequenceable collections and arrays, dictionaries and plain objects. Every other Pharo object reaches JS as a binding: a JS object with one method per exposed selector, named PharoJS-style (#at:put: becomes at_put_). A binding coming back from JS is the Pharo object itself.
Values cross in both directions the same way: nil and null (undefined becomes nil too), Booleans, numbers as Integer or Float, strings, sequenceable collections and arrays, dictionaries and plain objects. Every other Pharo object reaches JS as a binding: a JS object with one method per exposed selector. A method names itself in JS with a pragma (`at: key put: value <jsName: 'atPut'>`); without one the name is PharoJS-style (#at:put: becomes at_put_). Which selectors are exposed and what they are called are separate (see QJSBinding). A binding coming back from JS is the Pharo object itself.

context at: 'grid' put: aGrid exposing: #(name rows).
context at: 'grid' put: aGrid exposing: { AGGrid -> #(name rows). AGRow -> #(id name) }.
Expand Down Expand Up @@ -113,8 +113,8 @@ QJSContext >> bindingBoxFor: anObject exposure: aDictionary [
binding := QJSBinding object: anObject exposure: aDictionary.
id := runtime registerBinding: binding.
box := self library context: handle newBinding: id.
binding selectors do: [ :each |
self library context: handle define: box name: (self jsNameOf: each) id: id selector: each ].
binding names keysAndValuesDo: [ :name :selector |
self library context: handle define: box name: name id: id selector: selector ].
^ box
]

Expand Down Expand Up @@ -245,13 +245,6 @@ QJSContext >> isLive [
^ self isFromThisSession and: [ (self library contextIsLive: handle) = 1 ]
]

{ #category : 'private - to JS' }
QJSContext >> jsNameOf: aSelector [
"PharoJS-style: #at:put: becomes at_put_"

^ aSelector copyReplaceAll: ':' with: '_'
]

{ #category : 'accessing' }
QJSContext >> library [

Expand Down
40 changes: 40 additions & 0 deletions src/QuickJS-Tests/QJSBindingTest.class.st
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,34 @@ QJSBindingTest >> testFollowUpObjectsUseTheTable [
self assert: (context eval: 'grid.rowAt_(2).name()') equals: 'b'
]

{ #category : 'tests' }
QJSBindingTest >> testInheritedPragmaAndOverrideWithout [
"The name comes from the method the object runs: #at:put: inherited with its pragma, #at: overridden without one"

context
at: 'grid'
put: (QJSTestSubGrid named: 'Kunden' rows: #( ))
exposing: { (QJSTestGrid -> #( at:put: at: )) }.
self assert: (context eval: 'grid.atPut("k", 4); grid.at_("k")') equals: 40.
self assert: (context eval: 'typeof grid.get') equals: 'undefined'
]

{ #category : 'tests' }
QJSBindingTest >> testKeywordSelectorIsNamedLikePharoJS [

context at: 'grid' put: grid exposing: #( rowAt: ).
self assert: (context eval: 'typeof grid.rowAt_') equals: 'function'
]

{ #category : 'tests' }
QJSBindingTest >> testNameAloneDoesNotExpose [
"Naming and permission are separate: a method with <jsName:> that is not exposed does not exist in JS"

context at: 'grid' put: grid exposing: #( name ).
self assert: (context eval: 'typeof grid.atPut') equals: 'undefined'.
self assert: (context eval: 'typeof grid.at_put_') equals: 'undefined'
]

{ #category : 'tests' }
QJSBindingTest >> testNestedEvaluationFromPharo [

Expand All @@ -92,6 +113,15 @@ QJSBindingTest >> testNestedEvaluationFromPharo [
self assert: (context eval: 'grid.nested() + 1') equals: 43
]

{ #category : 'tests' }
QJSBindingTest >> testPragmaNamesTheMethod [

context at: 'grid' put: grid exposing: #( at:put: at: rowAt: ).
self assert: (context eval: 'grid.atPut("k", 42); grid.get("k")') equals: 42.
self assert: (context eval: 'typeof grid.at_put_') equals: 'undefined'.
self assert: (context eval: 'typeof grid.rowAt_') equals: 'function'
]

{ #category : 'tests' }
QJSBindingTest >> testPharoErrorBecomesJSError [

Expand Down Expand Up @@ -125,6 +155,16 @@ QJSBindingTest >> testReleasedBindingsAreDropped [
self assert: runtime bindingCount equals: 0
]

{ #category : 'tests' }
QJSBindingTest >> testTwoSelectorsWithOneNameAreRefused [

self
should: [ context at: 'grid' put: grid exposing: #( name nameAgain ) ]
raise: QJSError
withExceptionDo: [ :error | self assert: (error messageText includesSubstring: 'JS name name') ].
self assert: runtime bindingCount equals: 0
]

{ #category : 'tests' }
QJSBindingTest >> testUnexposedSelectorDoesNotExist [

Expand Down
28 changes: 26 additions & 2 deletions src/QuickJS-Tests/QJSTestGrid.class.st
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ Class {
#instVars : [
'name',
'rows',
'context'
'context',
'values'
],
#category : 'QuickJS-Tests',
#package : 'QuickJS-Tests'
Expand All @@ -19,6 +20,20 @@ QJSTestGrid class >> named: aString rows: names [
^ self new setName: aString rows: names
]

{ #category : 'accessing' }
QJSTestGrid >> at: aKey [

<jsName: 'get'>
^ values at: aKey ifAbsent: [ nil ]
]

{ #category : 'accessing' }
QJSTestGrid >> at: aKey put: aValue [

<jsName: 'atPut'>
^ values at: aKey put: aValue
]

{ #category : 'accessing' }
QJSTestGrid >> context: aContext [

Expand Down Expand Up @@ -49,6 +64,14 @@ QJSTestGrid >> name [
^ name
]

{ #category : 'accessing' }
QJSTestGrid >> nameAgain [
"Takes a name another selector already has"

<jsName: 'name'>
^ name
]

{ #category : 'accessing' }
QJSTestGrid >> nested [
"Evaluates JS while JS is calling this method"
Expand Down Expand Up @@ -78,5 +101,6 @@ QJSTestGrid >> secret [
QJSTestGrid >> setName: aString rows: names [

name := aString.
rows := names collect: [ :each | QJSTestRow named: each ]
rows := names collect: [ :each | QJSTestRow named: each ].
values := Dictionary new
]
15 changes: 15 additions & 0 deletions src/QuickJS-Tests/QJSTestSubGrid.class.st
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"
A stand-in that inherits #at:put: with its <jsName:> and overrides #at: without one.
"
Class {
#name : 'QJSTestSubGrid',
#superclass : 'QJSTestGrid',
#category : 'QuickJS-Tests',
#package : 'QuickJS-Tests'
}

{ #category : 'accessing' }
QJSTestSubGrid >> at: aKey [

^ (super at: aKey) ifNotNil: [ :value | value * 10 ]
]
Loading