diff --git a/README.md b/README.md index 05bcbc7..17c9119 100644 --- a/README.md +++ b/README.md @@ -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 + + ... +``` + +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: { diff --git a/src/QuickJS-Core/QJSBinding.class.st b/src/QuickJS-Core/QJSBinding.class.st index 661032f..fb7ee19 100644 --- a/src/QuickJS-Core/QJSBinding.class.st +++ b/src/QuickJS-Core/QJSBinding.class.st @@ -1,7 +1,9 @@ " -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 `; 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', @@ -9,7 +11,8 @@ Class { #instVars : [ 'object', 'exposure', - 'selectors' + 'selectors', + 'names' ], #category : 'QuickJS-Core', #package : 'QuickJS-Core' @@ -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 [ @@ -33,6 +43,27 @@ QJSBinding >> exposure [ ^ exposure ] +{ #category : 'naming' } +QJSBinding >> jsNameOf: aSelector [ + "The name the method the object runs for aSelector gives itself with , 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: ' 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 [ @@ -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 [ @@ -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 ] diff --git a/src/QuickJS-Core/QJSContext.class.st b/src/QuickJS-Core/QJSContext.class.st index c1e1e38..3a28f6d 100644 --- a/src/QuickJS-Core/QJSContext.class.st +++ b/src/QuickJS-Core/QJSContext.class.st @@ -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 `); 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) }. @@ -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 ] @@ -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 [ diff --git a/src/QuickJS-Tests/QJSBindingTest.class.st b/src/QuickJS-Tests/QJSBindingTest.class.st index 99ecfd9..eb7b3dd 100644 --- a/src/QuickJS-Tests/QJSBindingTest.class.st +++ b/src/QuickJS-Tests/QJSBindingTest.class.st @@ -77,6 +77,18 @@ 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 [ @@ -84,6 +96,15 @@ QJSBindingTest >> testKeywordSelectorIsNamedLikePharoJS [ self assert: (context eval: 'typeof grid.rowAt_') equals: 'function' ] +{ #category : 'tests' } +QJSBindingTest >> testNameAloneDoesNotExpose [ + "Naming and permission are separate: a method with 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 [ @@ -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 [ @@ -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 [ diff --git a/src/QuickJS-Tests/QJSTestGrid.class.st b/src/QuickJS-Tests/QJSTestGrid.class.st index 418689c..cbca054 100644 --- a/src/QuickJS-Tests/QJSTestGrid.class.st +++ b/src/QuickJS-Tests/QJSTestGrid.class.st @@ -7,7 +7,8 @@ Class { #instVars : [ 'name', 'rows', - 'context' + 'context', + 'values' ], #category : 'QuickJS-Tests', #package : 'QuickJS-Tests' @@ -19,6 +20,20 @@ QJSTestGrid class >> named: aString rows: names [ ^ self new setName: aString rows: names ] +{ #category : 'accessing' } +QJSTestGrid >> at: aKey [ + + + ^ values at: aKey ifAbsent: [ nil ] +] + +{ #category : 'accessing' } +QJSTestGrid >> at: aKey put: aValue [ + + + ^ values at: aKey put: aValue +] + { #category : 'accessing' } QJSTestGrid >> context: aContext [ @@ -49,6 +64,14 @@ QJSTestGrid >> name [ ^ name ] +{ #category : 'accessing' } +QJSTestGrid >> nameAgain [ + "Takes a name another selector already has" + + + ^ name +] + { #category : 'accessing' } QJSTestGrid >> nested [ "Evaluates JS while JS is calling this method" @@ -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 ] diff --git a/src/QuickJS-Tests/QJSTestSubGrid.class.st b/src/QuickJS-Tests/QJSTestSubGrid.class.st new file mode 100644 index 0000000..110baa4 --- /dev/null +++ b/src/QuickJS-Tests/QJSTestSubGrid.class.st @@ -0,0 +1,15 @@ +" +A stand-in that inherits #at:put: with its 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 ] +]