├── .ensime ├── .gitignore ├── README.rst ├── agent ├── bin │ ├── server │ └── server.bat ├── build.sbt └── src │ ├── main │ └── scala │ │ └── com │ │ └── codecommit │ │ └── cccp │ │ ├── agent │ │ ├── AsyncSocketAgent.scala │ │ ├── ClientFileActor.scala │ │ ├── Main.scala │ │ ├── ServerChannel.scala │ │ ├── SwankProtocol.scala │ │ └── state.scala │ │ └── util │ │ └── SExp.scala │ └── test │ └── scala │ └── com │ └── codecommit │ └── cccp │ └── agent │ └── ClientStateSpecs.scala ├── build.sbt ├── clients └── jedit │ ├── build.sbt │ ├── local-deploy.bat │ ├── local-deploy.sh │ └── src │ └── main │ ├── resources │ ├── actions.xml │ └── plugin.props │ └── scala │ └── com │ └── codecommit │ └── cccp │ └── jedit │ ├── AsyncSocketAgent.scala │ ├── Backend.scala │ ├── CCCPOptionPane.scala │ ├── CCCPPlugin.scala │ └── SExp.scala ├── project └── Project.scala └── server ├── bin ├── cccp-server └── cccp-server.bat ├── build.sbt ├── default.conf ├── lib └── fedone-api-0.2.jar └── src ├── main └── scala │ └── com │ └── codecommit │ └── cccp │ ├── Op.scala │ ├── OpFormat.scala │ └── server │ ├── CCCPService.scala │ ├── FilesActor.scala │ ├── Main.scala │ ├── OTActor.scala │ ├── OpChunkUtil.scala │ └── OpHistory.scala └── test └── scala └── com └── codecommit └── cccp ├── OpFormatSpecs.scala └── server └── OpHistorySpecs.scala /.ensime: -------------------------------------------------------------------------------- 1 | (:project-name "cccp" 2 | :use-sbt t 3 | :sbt-subprojects ( 4 | (:name "cccp-server" :deps ()) 5 | (:name "cccp-agent" :deps ("cccp-server")) 6 | (:name "cccp-jedit-client" :deps ()))) 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | project/target/ 3 | dist/ 4 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | This project provides a generic server and sub-process agent for implementing 2 | cross-editor real-time, character by character collaboration (a la SubEthaEdit, 3 | Gobby or Google Docs). Support is currently provided for the following editors: 4 | 5 | * jEdit_ 6 | * Emacs_ (via cccp-emacs_) 7 | 8 | The editor-specific plugins are extremely tiny and merely delegate all work to an 9 | editor-agnostic sub-process. This means that it should be extremely easy to 10 | add CCCP support to almost any editor that supports extension. 11 | 12 | Currently, the only functionality provided is character-by-character simultaneous 13 | co-edit between any number of editors. Future functionality may include things 14 | like: 15 | 16 | * **Buffer set linking** – This would allow collaborators to "follow" each-other's 17 | actions not just in terms of edits, but in terms of active buffers and their 18 | respective positions. In this case, one collaborator would be the "master" 19 | while the others followed along. 20 | * **File discovery** – Rather than having to share an opaque file identifier, the 21 | server would expose a list of active files to the editors, allowing users to 22 | link their buffers by selecting the appropriate file from a list. 23 | * **Commit Coordination** – At present, there is no VCS-specific support. This 24 | means that when you're ready to commit, you must nominate just one person to 25 | do the commit and then sync back up again. This can be a pain. With certain 26 | VCS (like Git), it would be possible to run the commit through CCCP and perform 27 | the same commit (with the same author information) simultaneously on *both* ends, 28 | resulting in the same commit being shared between collaborators without a separate 29 | ``fetch`` action. 30 | 31 | 32 | Usage 33 | ===== 34 | 35 | If you want to use CCCP, you will first need to build both the ``server`` and 36 | ``agent`` modules. This can be done using the ``stage`` task in the SBT_ build. 37 | This task will create a ``dist/`` directory under whatever project you use it 38 | against. Inside this directory will be *all* of the dependencies required to 39 | run that particular module, as well as a pair of scripts (for Windows and *nix) 40 | which launch that process. If you don't have SBT on your system, you can find 41 | instructions on how to get running here: https://github.com/harrah/xsbt/wiki/Getting-Started-Setup. 42 | Essentially all you need to do is launch the ``sbt-launch.jar`` file in the project 43 | directory. 44 | 45 | If you don't feel like installing from source, you can use the pre-built binaries 46 | available in the Downloads section. Binaries are available for both the server 47 | and the agent. 48 | 49 | The server process is a very straight forward HTTP server built on BlueEyes_, 50 | which is in turn built on Netty_. When you launch its process, you will need to 51 | pass the ``--configFile `` option, where ```` is replaced with your 52 | server configuration file. The following template should be sufficient:: 53 | 54 | server { 55 | port = 8585 56 | sslPort = 8586 57 | } 58 | 59 | (note: you can find these contents in the ``default.conf`` file in the server 60 | module) 61 | 62 | The server will remain up and running until killed with Ctrl+C. Note that Netty 63 | will reject the ``HUP`` signal if there are still outstanding client connections. 64 | You should run the server in some place that is accessible to all clients involved. 65 | All edit operations will be proxied through this server, which is handling the 66 | server-side OT_ for CCCP. You can find more details on this process below. 67 | 68 | The second module you will need to build is the agent. This is the editor agnostic 69 | sub-process that will be *used* by any editor-specific plugins providing CCCP 70 | functionality. You do not run this process directly. Just build it and take note 71 | of its output directory. 72 | 73 | Once you have all of this setup, you can now configure your editor-specific plugin. 74 | This will involve entering the root directory of the agent build (this should be 75 | ``dist/`` in the agent module directory) as well as the protocol, host and port 76 | to be used to connect to the server. 77 | 78 | If everything is working, you should be able to link a buffer in your editor. 79 | This process will prompt you for an id for that buffer. This id is used to 80 | identify the file you are linking so that collaborators need not use the exact 81 | same file names. Linking the file merely creates the registration with the server, 82 | it does not upload any data! 83 | 84 | With the file linked, you can begin editing. Other collaborators can link with 85 | the same id. The critical restriction is that they must *start* from exactly the 86 | same file state as you did when you first linked the file. One good way to ensure 87 | this is to make everyone start from the same clean Git revision. Any edits you 88 | have performed since linking will be sent down to the new collaborators the moment 89 | they link their buffers. 90 | 91 | After this point, all edits will be synced character-by-character between all 92 | linked buffers. Collaborators can type simultaneously at different points (or 93 | even the *same* point) in the file. Conflicts are resolved by the server in a 94 | uniform way, so the protocol never "fails". If your connection is more latent 95 | than your keyboard, edits may be chunked together slightly. This is entirely 96 | normal. The chunk sizes will automatically adjust themselves *immediately* in 97 | response to the network latency between your agent and the server, making the 98 | protocol extremely self-healing. You can even disconnect entirely from the server, 99 | perform a large number of edits, and they will all be synced in a large chunk 100 | as soon as your connection is re-established. 101 | 102 | There is one important restriction here: you *cannot* change files outside of the 103 | editor. If you do this, the collaboration will get out of sync and the server 104 | will reject your changes. If you want to change a file outside the editor, you 105 | will need to unlink that buffer, change the file and then relink when you are done. 106 | 107 | 108 | jEdit 109 | ----- 110 | 111 | First, you need to install the CCCP plugin. The best way to do this (right now) 112 | is to install from source. To do this, clone the project, ``cd`` to the root of 113 | the project directory and run the following commands:: 114 | 115 | $ sbt 116 | > project cccp-jedit-client 117 | > stage 118 | > exit 119 | $ cd clients/jedit/ 120 | $ ./local-deploy.sh 121 | 122 | This assumes that you have set the ``JEDIT_HOME`` environment variable. Once 123 | you have performed these steps, the plugin will be installed and ready to use in 124 | your jEdit installation (note: jEdit may be open during this time). To activate 125 | the plugin, open up the Plugin Manager (action ``plugin-manager``) and activate 126 | ``CCCP.jar``. 127 | 128 | Alternatively, if you prefer not to install from source, you can use the pre-built 129 | binary provided in this project's Downloads section. Note that this binary may 130 | be slightly out of date, depending on whether or not I have remembered to update 131 | it. Copy this JAR file to the ``jars/`` directory in your jEdit settings directory. 132 | Note that you must also copy the ``scala-library.jar`` file from the Scala 2.9.1 133 | distribution. Make sure it is *exactly* this version! If you have another plugin 134 | that depends on Scala, well, let's hope it depends on the same version. 135 | 136 | Once you have the plugin up and running, the first thing you should do is configure 137 | it settings. Open up Plugin Options (action ``plugin-options``) and navigate to 138 | the CCCP pane (note that the UI here is a work in progress). You need to set 139 | four settings: 140 | 141 | * **CCCP Agent Home** – This should be the directory containing the CCCP agent 142 | distribution. You can obtain this from the Downloads area, or built the agent 143 | from source using the ``stage`` task. 144 | * **Protocol** – This should be either ``http`` or ``https``. Don't try to get 145 | cute with ``spdy`` or ``rsync``, they will not work! This is the protocol used 146 | to transfer data from the agent to the server. 147 | * **Host** – The host name (or IP) hosting the CCCP server instance. Note that 148 | IPv6 is supported, but you should bracket the address to avoid any ambiguity. 149 | * **Port** – The port number on which the CCCP server is listening. 150 | 151 | Once you save the settings, jEdit will reset the CCCP agent! This means that any 152 | currently-linked buffers will be unlinked. 153 | 154 | Linking 155 | ~~~~~~~ 156 | 157 | Whenever you want to collaborate on a particular buffer, you must *link* that 158 | buffer with the server. When you link with the server, you will choose a unique 159 | identifier for this buffer. Your collaborators will link with your buffer by 160 | simply linking with the server using this exact same identifier. 161 | 162 | Before you link, you should ensure that your buffer is in a known "start" state. 163 | Starting from a clean Git revision that is shared with your collaborators is a 164 | good way to go. Any changes made to your buffer after linking will be forwarded 165 | to the server. This way, collaborators are free to link with the server using 166 | the same buffer id at any point (even after you have been editing for a while). 167 | The important point is that their buffer is in the *exact* same start state as 168 | your buffer started in. A simple workflow might go as follows: 169 | 170 | 1. **User A** checks out commit ``cafebabe`` 171 | 2. **User A** opens file ``Foo.scala`` and links the buffer using id ``foo-scala`` 172 | 3. **User A** starts editing ``Foo.scala`` (note that the save action is not 173 | significant and may be performed at will) 174 | 4. **User B** checks out commit ``cafebabe`` 175 | 5. **User A** is still editing, saving, committing, and generally being productive 176 | 6. **User B** opens file ``Foo.scala`` and links the buffer using id ``foo-scala`` 177 | 7. All of the changes performed by **User A** since linking the buffer initially 178 | will be performed on **User B**'s buffer. Note that **User B** does not need 179 | to wait for this to happen; s/he is free to start editing immediately 180 | 8. After a little bit of syncing, **User A** and **User B** will be in perfect 181 | sync and will see each other's edits in real-time, character-by-character 182 | 9. Eventually, **User A** decides s/he has had enough of this collaboration 183 | nonsense and unlinks the buffer 184 | 10. **User B** can continue editing, with the changes being forwarded to the server. 185 | Since **User A** has unlinked, its edits will not be forwarded to the server 186 | and thus not shared with **User B**. Likewise, the edits from **User B** will 187 | not be forwarded to **User A**. 188 | 189 | At the end of this string of events, **User A** may reconsider unlinking and want 190 | to relink with the server. In order to do this without corrupting the local state, 191 | **User A** must first discard all local changes and revert back to commit ``cafebabe``. 192 | Once they link, *all* changes (including those performed by **User A**) will be 193 | reapplied to the buffer, bringing it back into sync with the current collaborative 194 | state. 195 | 196 | The core concept to understand is that the server maintains a list of deltas which 197 | will take a buffer from a single starting state to the current shared state. If 198 | a user attempts to apply those deltas to a starting state other than the starting 199 | state assumed by the server, the result will be a buffer that is permanently out 200 | of sync. Collaborative edits performed on this buffer will likely be rejected by 201 | the server and thus never forwarded onto other users. 202 | 203 | 204 | Agent Protocol 205 | ============== 206 | 207 | The agent protocol is based on SWANK, which is the protocol used by SLIME_ and 208 | ENSIME_ to communicate with Emacs. The essence of the protocol is just sending 209 | s-expressions over a raw socket with run-length prefixes. The best description 210 | I've found of this process is from the ENSIME manual: 211 | 212 | To send an s-expression, determine its ASCII length and then encode that 213 | integer as a padded six-digit hexadecimal value. Write this value to the 214 | output socket first, then followed by the ASCII form of the s-expression. On 215 | the receiving side, the reader loop should read six ASCII characters into a 216 | buffer and convert that into an integer, then read that number of ASCII 217 | characters from the socket, parsing the result into an s-expression. 218 | 219 | .. image:: http://aemon.com/file_dump/wire_protocol.png 220 | 221 | Each SWANK RPC call is of the following form:: 222 | 223 | (:swank-rpc
) 224 | 225 | For example, if you wanted to invoke the ``edit-file`` RPC as call id 42, the 226 | s-expression would look like the following:: 227 | 228 | (:swank-rpc (swank:edit-file "file.txt" (:retain 4 :insert "ing" :retain 1)) 42) 229 | 230 | The actual ASCII bytes sent over the socket would be as follows:: 231 | 232 | 000050(:swank-rpc (swank:edit-file "file.txt" (:retain 4 :insert "ing" :retain 1)) 42) 233 | 234 | The call id should be unique for each RPC invocation, but beyond that it has no 235 | restrictions. Returns for a particular call will use its call id, though this 236 | feature is not relevant for CCCP as none of the calls have returns. 237 | 238 | Invocations from the agent to the editor are less restricted. Generally, they can 239 | be of any agreed-upon form. They still use run-length prefixing and s-expressions, 240 | but beyond that any form is allowed. See the Editor API. 241 | 242 | Agent API 243 | --------- 244 | 245 | * ``(swank:init-connection (:protocol protocol :host host :port port))`` 246 | 247 | Initializes the agent's connection to the server. Note that the agent will 248 | not actually test this connection, it will merely configure for later HTTP calls. 249 | This RPC *must* be invoked prior to anything else and may only be called once. 250 | * ``(swank:link-file id file-name)`` 251 | 252 | Creates a new buffer linkage for a particular identifier. This identifier will 253 | be used whenever the agent sends operations on this buffer to the server. Thus, 254 | if you want to link a buffer between two editors, you would simply link them 255 | both to the same identifier. The file name is only significant in that it must 256 | be the file name included in the ``swank:edit-file`` invocations which perform 257 | the actual edits. This is done so that the editor plugin does not have to 258 | maintain its own internal mapping from file names to identifiers. 259 | 260 | This call must be made prior to editing the file and can only be made once. 261 | Note that it is possible to relink buffers after having previously unlinked 262 | them. However, this requires that the buffer be in *exactly* the same state as 263 | any buffers that remained linked, or the same state as the last buffer to be 264 | unlinked at the point at which it was unlinked. Generally speaking, it is just 265 | safer to link on a fresh identifier when relinking a buffer. 266 | * ``(swank:unlink-file file-name)`` 267 | 268 | Removes a linkage for a particular file. Remote updates will not be 269 | propagated to the buffer once this call has run. This also frees any resources 270 | in the agent that are associated with the linkage. Please note that in cases 271 | of high-latency, there may be changes local to the agent that have not yet 272 | transmitted to the server. These changes will *not* be sent if ``unlink-file`` 273 | happens before such time as that is possible. The editor local buffer will 274 | still have the changes, but they will never reach the server. 275 | * ``(swank:edit-file file-name (...))`` 276 | 277 | This is the most important API call. This call should be made on every buffer 278 | change. The inner-form is the description of the buffer change and must be an 279 | ordered property list of the form ``(:key1 value1 :key2 value2)``. The exact 280 | schema for this property list should be as follows: 281 | 282 | * ``:retain`` – Must correspond to an integer value. Specifies an offset into 283 | the file. 284 | * ``:insert`` – Must correspond to a string value. Specifies a text string to 285 | insert at the current location. 286 | * ``:delete`` – Must correspond to a string value. Specifies a text string to 287 | delete from the current location. 288 | 289 | There are a few things that are important to understand about this format. First, 290 | the offsets must span the *entire* file. Thus, if you add up all of the ``:retain`` 291 | values, plus the length of the ``:insert`` and ``:delete`` strings, it must 292 | equal the total character length of the buffer. In the case of ``:insert``, this 293 | is the total length *after* application of the operation; in the case of ``:delete``, 294 | it is the total length *before* application of the operation. Note that this 295 | metaphor only makes sense if you have either an ``:insert`` or a ``:delete``, 296 | but not both. This is a weakness in the line of thought, since it is very 297 | possible to have an operation which performs both actions (e.g. if text is selected 298 | and replaced with some new text in an atomic action). A truer way of looking at 299 | operation offsets would be to view the operation as an ordered set of instructions 300 | to a cursor walking through the buffer from start to finish. The cursor *must* 301 | traverse the entire document. 302 | 303 | Note that operations sent from the editor to the agent are likely to be single-action 304 | operations with a leading and trailing retain. This is extremely *unlikely* to 305 | be the case for operations coming from the agent to the editor. This is because 306 | the protocol composes operations together when latency exceeds typist speed (the 307 | normal mode of operation). As a result, the editor code which handles operations 308 | must be able to handle multiple actions in a single operation. For example: 309 | 310 | ``(:retain 4 :delete "bar" :insert "foo" :retain 127 :insert "baz" :retain 10)`` 311 | 312 | The jEdit plugin handles this by converting each ``:delete`` and ``:insert`` 313 | action into its own separate operation with offset and contents. These actions 314 | are then applied *in order* (the ordering bit is very important, otherwise the 315 | offsets will not be correct for actions subsequent to the first in the operation). 316 | 317 | Just to give an example of an operation, we would insert the text ``here`` at 318 | offset ``11133`` with a total buffer length of ``11430`` using the following 319 | operation: 320 | 321 | ``(:retain 11133 :insert "here" :retain 297)`` 322 | 323 | It is very important that operation application and synthesis is implemented 324 | correctly in the editor-specific plugins. Bugs in this code will result in 325 | incorrectly-synchronized buffers and errors in the agent, the server, or both. 326 | For more details on operations, see `this article on OT`_ as well as `the documentation`_ 327 | at http://www.waveprotocol.org. CCCP does not implement the Wave protocol, 328 | but it does use Wave's OT algorithms and operation abstractions. 329 | * ``(swank:shutdown)`` 330 | 331 | Causes the agent process to gracefully shutdown. This call should be used 332 | instead of just killing the sub-process. While killing the process will *work*, 333 | the ``swank:shutdown`` call gives the agent a chance to clean up registrations 334 | on the server. 335 | 336 | Callbacks 337 | --------- 338 | 339 | In order for changes to be pushed back from the server to the client, the agent 340 | must make a call proactively to the client, not as a response to any particular 341 | message. This must also happen for things like errors, malformed messages and 342 | similar. SWANK provides a mechanism for reporting errors on specific calls, and 343 | thus the only callbacks which are unique (and require documentation) are those 344 | synthesized by the agent. Currently, there is only one of these: 345 | 346 | * ``(:edit-performed file-name (...))`` 347 | 348 | This call indicates that an operation has been applied to the given file, and 349 | that operation is represented by the specified form. The format used to 350 | represent an operation is the same as the one used by the ``swank:edit-file`` 351 | RPC. Note that this call will only take place for operations which *need* to 352 | be applied to the local buffer. Thus, local operations (which are already in 353 | the buffer) will not result in this callback, only remote operations. These 354 | remote operations will have already passed through the OT process, and thus 355 | should be directly insertable into the local buffer. 356 | 357 | Gory Details 358 | ============ 359 | 360 | CCCP fully implements an optimistic concurrency control mechanism called "operational 361 | transformation". This is what allows real-time collaborative editing on a single 362 | document to proceed without each editor waiting for a server round-trip before 363 | inserting or removing characters. Before we dive into how this works, we need 364 | to establish a little vocabulary: 365 | 366 | * **operation** – a command to change the edit buffer consisting of zero or more 367 | *actions* applied in a cursor style, spanning the entire buffer 368 | * **action** – an individual component of an *operation*, indicating that text 369 | should be added or removed (depending on the action type) 370 | * **transformation** – the process of adjusting or "fixing" operations to that 371 | they can be reordered between clients without affecting the net composite 372 | * **composition** – the process of taking two operations that apply to the same 373 | document and deriving one operation which represents the net change of the two 374 | when applied to the original document 375 | * **client** – the editor itself 376 | * **agent** – the editor sub-process which handles the client-side work 377 | * **server** – the server process which handles the server-side work 378 | * **document** – a term I will use interchangably with *edit buffer* 379 | 380 | The fundamental problem with real-time collaborative editing is that changes are 381 | occuring simultaneously at various positions in the document. Each editor needs 382 | to apply its operations locally without delay. This is a critical "feature" as 383 | it is what allows input responsiveness in the client. Unfortunately, if editor 384 | **A** inserts two characters at offset 12 while simultaneously editor **B** inserts 385 | five characters at offset 20, there is potential for document corruption. 386 | 387 | This is really the classic diamond problem in concurrency control. Editor **A** 388 | applies its operation locally and sends it to **B**. Meanwhile, editor **B** 389 | applies its operation locally and sends it to editor **A**. However, when editor 390 | **A** attempts to apply the operation from editor **B**, it will perform the 391 | insertion at offset 20, which is *not* the location in the document that **B** 392 | intended. The actual intended location has become offset 22 due to the two new 393 | characters inserted by **A** prior to receiving the operation from **B**. This 394 | is the problem that OT solves. 395 | 396 | The first step in solving this problem is to handle the simple diamond problem 397 | illustrated above. Two editors apply operations *a* and *b* simultaneously. 398 | We need to derive two transformed operations *a'* and *b'* such that *a + b'* = 399 | *b + a'*. This process is mostly just adjusting offsets and shuffling text in 400 | one direction or another, and it is fully implemented by the Wave OT algorithm. 401 | The exact details of this process are beyond the scope of this README. 402 | 403 | There is one slight niggling detail here: what happens if we have *three* editors, 404 | **A**, **B** and **C**? A key insight of the Jupiter collaboration system (the 405 | primary theoretical foundation for Wave) is that it is possible to collapse this 406 | problem into the two-editor case by introducing a client-server architecture. 407 | Effectively, there are only ever two editors at a time: the client and the server. 408 | When operations are applied on the server, they are mirrored back to every other 409 | client. This also provides a uniform way of resolving conflicts: just find in 410 | favor of the server every time. Naturally, this is a race condition, and it may 411 | result in unexpected document states surrounding simultaneous edits at the *same* 412 | offset, but the point is that the document states will be uniform across *all* 413 | clients, and so users are able to simply cursor back and "fix" the change as they 414 | see fit. 415 | 416 | Unfortunately, solving the one-step diamond is insufficient to enable real-time 417 | collaborative editing. The reason for this is best illustrated with an example. 418 | Editor **A** applies an operation *a1* and then immediately follows it up with *a2*. 419 | Perhaps **A** is typing at more than one or two characters per second. Meanwhile, 420 | the server has applied an operation from editor **B**, *b1*. **A** sends *a1* to 421 | the server while the server simultaneously sends *b1* to **A**. This will result 422 | in an application of OT to derive *a1'* (on the server) and *b1'* (on the client), 423 | and that's all well and good. However, **A** also needs to send operation *a2* 424 | to the server, and this is where we hit a snag. 425 | 426 | The problem is that *a2* is an operation that applies to the document state following 427 | *a1*, *not* respecting *b1*! Thus, *a2* requires a document state that the server 428 | does not have. **A** will send *a2* to the server and the server will be unable 429 | to apply, transform or otherwise make use of the operation, resulting in editor 430 | state corruption. 431 | 432 | There are two ways to solve this problem. The first, and the one used by Jupiter 433 | and almost every other OT-based collaborative system is for the server to track 434 | every individual client's state in vector space. Basically, the server must not 435 | only apply *a1'* to its internal state, it must also apply *a1* to an *earlier* 436 | state, creating an in-memory fork of the server state that will be preserved until 437 | **A** comes back into sync with the server. In the case where multiple editors 438 | are typing simultaneously, this could potentially take a very long time. The 439 | *normal* state for editors using OT is to be walking entirely different state 440 | spaces from each other, only coming back into full sync once everything "calms down". 441 | This produces a very nice user experience, but it also means that the server 442 | would need to track the full (and potentially lengthy) histories for every single 443 | client, producing a large amount of overhead. 444 | 445 | This doesn't scale well. Google's key innovation with Wave was to restrict client 446 | behavior so that **A** can never send *a2* directly to the server. Instead, **A** 447 | must wait for the confirmation that the server has applied *a1*, at which point 448 | **A** will use the operations it has received from the server in the interim to 449 | infer the current state of the server's document and edit history. Using this 450 | information, **A** will transform *a2* into *a2'* and send *that* operation to 451 | the server. Now, the server may still need to transform *a2'* against subsequent 452 | operations that hadn't been received by **A** at the time of transmission, but 453 | that's not a problem. As long as *a2'* is rooted in server state space, the 454 | server will be able to perform this transformation and will only need to track 455 | its own history. 456 | 457 | In terms of version control systems, you can think of this like the clients 458 | constantly rebasing their history against a central repository, rather than pushing 459 | an *entire* branch and attempting to merge at the end. It's a great deal more 460 | work for the clients, but it means that the server only needs to maintain a 461 | linear history, regardless of the number of clients. 462 | 463 | Unfortunately, Wave doesn't provide this for us. Its code for this purpose is 464 | Wave-specific, and so cannot be repurposed for other things. For this reason, 465 | CCCP has to provide its own implementation of this logic (``state.scala``). 466 | 467 | At the end of the day, the result is a collaborative editing system 468 | that allows character-by-character changes to be shared in real time across *any* 469 | number of clients with varying latencies. The protocol heals itself and degrades 470 | gracefully, chunking together updates when the server is taking a long time to 471 | report back with the confirmation of the previous operation. This self-healing 472 | is so flexible that you can actually take your editor completely offline for any 473 | length of time! The edits will simply buffer up, awaiting confirmation. Once 474 | the network connection is reestablished, the confirmation will finally arrive, 475 | the buffer will flush to the server in one chunk and everything will sync-up once 476 | again. 477 | 478 | 479 | .. _jEdit: http://jedit.org 480 | .. _Emacs: http://www.gnu.org/s/emacs/ 481 | .. _cccp-emacs: https://github.com/candera/cccp-emacs 482 | .. _SBT: https://github.com/harrah/xsbt/wiki 483 | .. _BlueEyes: https://github.com/jdegoes/blueeyes 484 | .. _Netty: http://www.jboss.org/netty 485 | .. _SLIME: http://common-lisp.net/project/slime/ 486 | .. _ENSIME: https://github.com/aemoncannon/ensime 487 | .. _OT: http://www.codecommit.com/blog/java/understanding-and-applying-operational-transformation 488 | .. _this article on OT: http://www.codecommit.com/blog/java/understanding-and-applying-operational-transformation 489 | .. _the documentation: http://wave-protocol.googlecode.com/hg/whitepapers/operational-transform/operational-transform.html 490 | -------------------------------------------------------------------------------- /agent/bin/server: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | THIS_DIR=`dirname $0` 4 | cd ${THIS_DIR}/.. 5 | 6 | exec java -classpath com.codecommit.cccp.agent.Main $* 7 | -------------------------------------------------------------------------------- /agent/bin/server.bat: -------------------------------------------------------------------------------- 1 | java -classpath com.codecommit.cccp.agent.Main %* 2 | -------------------------------------------------------------------------------- /agent/build.sbt: -------------------------------------------------------------------------------- 1 | import IO._ 2 | 3 | name := "cccp-agent" 4 | 5 | libraryDependencies ++= Seq( 6 | "se.scalablesolutions.akka" % "akka-actor" % "1.2", 7 | "com.reportgrid" %% "blueeyes" % "0.4.24", 8 | "org.specs2" %% "specs2" % "1.7-SNAPSHOT" % "test") 9 | 10 | resolvers ++= Seq( 11 | "Sonatype" at "http://nexus.scala-tools.org/content/repositories/public", 12 | "Scala Tools" at "http://scala-tools.org/repo-snapshots/", 13 | "JBoss" at "http://repository.jboss.org/nexus/content/groups/public/", 14 | "Akka" at "http://akka.io/repository/", 15 | "GuiceyFruit" at "http://guiceyfruit.googlecode.com/svn/repo/releases/") 16 | 17 | exportJars := true 18 | 19 | stage <<= (dependencyClasspath in Runtime, exportedProducts in Runtime) map { (depCP, exportedCP) => 20 | // this task "borrowed" from ENSIME (thanks, Aemon!) 21 | val agent = Path("agent") 22 | val log = LogManager.defaultScreen 23 | delete(file("dist")) 24 | log.info("Copying runtime environment to ./dist....") 25 | createDirectories(List( 26 | file("agent/dist"), 27 | file("agent/dist/bin"), 28 | file("agent/dist/lib"))) 29 | // Copy the runtime jars 30 | val deps = (depCP ++ exportedCP).map(_.data) 31 | copy(deps x flat(agent / "dist" / "lib")) 32 | // Grab all jars.. 33 | val cpLibs = (agent / "dist" / "lib" ** "*.jar").get.flatMap(_.relativeTo(agent / "dist")) 34 | def writeScript(classpath:String, from:String, to:String) { 35 | val tmplF = new File(from) 36 | val tmpl = read(tmplF) 37 | val s = tmpl.replace("", classpath) 38 | val f = new File(to) 39 | write(f, s) 40 | f.setExecutable(true) 41 | } 42 | // Expand the server invocation script templates. 43 | writeScript(cpLibs.mkString(":").replace("\\", "/"), "agent/bin/server", "agent/dist/bin/server") 44 | writeScript("\"" + cpLibs.map{lib => "%~dp0/../" + lib}.mkString(";").replace("/", "\\") + "\"", "agent/bin/server.bat", "agent/dist/bin/server.bat") 45 | // copyFile(root / "README.md", root / "dist" / "README.md") 46 | // copyFile(root / "LICENSE", root / "dist" / "LICENSE") 47 | } 48 | -------------------------------------------------------------------------------- /agent/src/main/scala/com/codecommit/cccp/agent/AsyncSocketAgent.scala: -------------------------------------------------------------------------------- 1 | package com.codecommit.cccp 2 | package agent 3 | 4 | import java.io.{BufferedWriter, InputStreamReader, OutputStreamWriter, Reader} 5 | import java.net.Socket 6 | import java.util.concurrent.{LinkedBlockingQueue, TimeUnit} 7 | import scala.collection.mutable.ArrayBuffer 8 | 9 | class AsyncSocketAgent(val socket: Socket, callback: String => Unit, failure: String => Unit) { self => 10 | private val queue = new LinkedBlockingQueue[String] 11 | 12 | private val writerThread = { 13 | val back = new Thread { 14 | override def run() { 15 | try { 16 | self.runWriter() 17 | } catch { 18 | case e => { 19 | failure("%s: %s".format(e.getClass.getSimpleName, e.toString)) 20 | throw e 21 | } 22 | } 23 | } 24 | } 25 | back.setPriority(3) 26 | back.start() 27 | back 28 | } 29 | 30 | private val readerThread = { 31 | val back = new Thread { 32 | override def run() { 33 | try { 34 | self.runReader() 35 | } catch { 36 | case e => { 37 | failure("%s: %s".format(e.getClass.getSimpleName, e.toString)) 38 | throw e 39 | } 40 | } 41 | } 42 | } 43 | back.setPriority(3) 44 | back.start() 45 | back 46 | } 47 | 48 | private var stopRequested = false 49 | 50 | def send(chunk: String) { 51 | queue.offer(chunk) 52 | } 53 | 54 | def stop() { 55 | stopRequested = true 56 | } 57 | 58 | private def runWriter() { 59 | val writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream)) 60 | 61 | try { 62 | while (!stopRequested) { 63 | val work = queue.poll(1, TimeUnit.SECONDS) 64 | if (work != null) { 65 | writer.write("%06x" format work.length) 66 | writer.write(work) 67 | writer.flush() 68 | } 69 | } 70 | } catch { case _ if stopRequested => } 71 | } 72 | 73 | private def runReader() { 74 | val reader = new InputStreamReader(socket.getInputStream) 75 | 76 | try { 77 | while (!stopRequested) { 78 | val totalBuffer = new ArrayBuffer[Char] 79 | var remaining = readHeader(reader) 80 | 81 | while (remaining != 0) { 82 | val buffer = new Array[Char](remaining) 83 | remaining -= reader.read(buffer) 84 | totalBuffer ++= buffer 85 | } 86 | 87 | callback(new String(totalBuffer.toArray)) 88 | } 89 | } catch { case _ if stopRequested => } 90 | } 91 | 92 | private def readHeader(reader: Reader) = { 93 | val header = new Array[Char](6) 94 | reader.read(header) 95 | Integer.valueOf(new String(header), 16) 96 | } 97 | } 98 | 99 | object AsyncSocketAgent { 100 | def sync[A](timeout: Long)(f: (A => Unit) => Unit): Option[A] = { 101 | var result: Option[A] = None 102 | val signal = new AnyRef 103 | 104 | f { asyncRes => 105 | signal synchronized { 106 | result = Some(asyncRes) 107 | signal.notifyAll() 108 | } 109 | } 110 | 111 | signal synchronized { 112 | if (result.isEmpty) { 113 | signal.wait(timeout) 114 | } 115 | } 116 | 117 | result 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /agent/src/main/scala/com/codecommit/cccp/agent/ClientFileActor.scala: -------------------------------------------------------------------------------- 1 | package com.codecommit.cccp 2 | package agent 3 | 4 | import akka.actor.{Actor, ActorRef} 5 | 6 | class ClientFileActor(id: String, fileName: String, callback: ActorRef, channel: ActorRef) extends Actor { 7 | import Actor._ 8 | import ClientFileActor._ 9 | import ServerChannel._ 10 | 11 | @volatile 12 | var state: ClientState = Synchronized(0) 13 | 14 | channel ! Poll(id, state.version + 1, self) 15 | 16 | def receive = { 17 | case op: Op => handleAction(state applyClient op) 18 | 19 | case EditsPerformed(_, ops) => { 20 | for (op <- ops) { 21 | handleAction(state applyServer op) 22 | } 23 | 24 | channel ! Poll(id, state.version + 1, self) 25 | } 26 | } 27 | 28 | private def handleAction(act: Action) = act match { 29 | case Send(op, state2) => { 30 | channel ! PerformEdit(id, op) 31 | state = state2 32 | } 33 | 34 | case Apply(op, state2) => { 35 | callback ! EditPerformed(fileName, op) 36 | state = state2 37 | } 38 | 39 | case Shift(state2) => { 40 | state = state2 41 | } 42 | } 43 | } 44 | 45 | object ClientFileActor { 46 | case class EditPerformed(fileName: String, op: Op) 47 | } 48 | -------------------------------------------------------------------------------- /agent/src/main/scala/com/codecommit/cccp/agent/Main.scala: -------------------------------------------------------------------------------- 1 | package com.codecommit.cccp 2 | package agent 3 | 4 | import akka.actor.Actor.actorOf 5 | 6 | import java.io._ 7 | import java.net._ 8 | 9 | object Main extends App { 10 | if (args.length < 1) { 11 | System.err.println("usage: bin/server ") 12 | System.exit(-1) 13 | } 14 | 15 | val portFile = new File(args.head) 16 | val server = new ServerSocket(0) 17 | 18 | val writer = new FileWriter(portFile) 19 | try { 20 | writer.write(server.getLocalPort.toString) 21 | } finally { 22 | writer.close() 23 | } 24 | 25 | actorOf(new SwankProtocol(server.accept())).start() 26 | } 27 | -------------------------------------------------------------------------------- /agent/src/main/scala/com/codecommit/cccp/agent/ServerChannel.scala: -------------------------------------------------------------------------------- 1 | package com.codecommit.cccp 2 | package agent 3 | 4 | import akka.actor.{Actor, ActorRef, Scheduler} 5 | 6 | import blueeyes.core.service.engines.HttpClientXLightWeb 7 | import blueeyes.core.http.MimeTypes._ 8 | 9 | import java.util.concurrent.TimeUnit 10 | 11 | class ServerChannel(protocol: String, host: String, port: Int) extends Actor { 12 | import ServerChannel._ 13 | import server.OpChunkUtil._ 14 | 15 | val MaxDelay = 30 * 1000 // 30 seconds 16 | 17 | val client = { 18 | val back = new HttpClientXLightWeb 19 | back.protocol(protocol).host(host).port(port).contentType(text/plain) 20 | } 21 | 22 | def receive = { 23 | case PerformEdit(id, op, delay) => { 24 | val chunk = opToChunk(Vector(op)) 25 | println("Sending chunk to server: " + (chunk.data map { _.toChar } mkString)) 26 | 27 | client.post("/" + id + "/")(chunk) ifCanceled { _ => 28 | val delay2 = math.max(delay * 2, MaxDelay) 29 | val randomDelay = (math.random * delay).toInt 30 | Scheduler.scheduleOnce(self, PerformEdit(id, op, delay2), randomDelay, TimeUnit.MILLISECONDS) 31 | } 32 | } 33 | 34 | case Poll(id, version, callback, delay) => { 35 | val pollResults = client.get("/" + id + "/" + version) 36 | 37 | pollResults ifCanceled { _ => 38 | val delay2 = math.max(delay * 2, MaxDelay) 39 | val randomDelay = (math.random * delay).toInt 40 | Scheduler.scheduleOnce(self, Poll(id, version, callback, delay2), randomDelay, TimeUnit.MILLISECONDS) 41 | } 42 | 43 | for (resp <- pollResults; content <- resp.content) { 44 | if (content.data.isEmpty) { 45 | self ! Poll(id, version, callback) 46 | } else { 47 | println("Received chunk from server:\n " + (content.data map { _.toChar } mkString).replace("\n", "\n ")) 48 | 49 | val ops = chunkToOp(content) 50 | callback ! EditsPerformed(id, ops) 51 | } 52 | } 53 | } 54 | } 55 | } 56 | 57 | object ServerChannel { 58 | case class PerformEdit(id: String, op: Op, delay: Int = 1) 59 | case class EditsPerformed(id: String, op: Seq[Op]) 60 | 61 | case class Poll(id: String, version: Int, callback: ActorRef, delay: Int = 1) 62 | } 63 | -------------------------------------------------------------------------------- /agent/src/main/scala/com/codecommit/cccp/agent/SwankProtocol.scala: -------------------------------------------------------------------------------- 1 | package com.codecommit.cccp 2 | package agent 3 | 4 | import akka.actor.{Actor, ActorRef, PoisonPill} 5 | 6 | import java.io.StringWriter 7 | import java.net.Socket 8 | import java.util.UUID 9 | 10 | import org.waveprotocol.wave.model.document.operation.DocOp 11 | import org.waveprotocol.wave.model.document.operation.DocOpComponentType 12 | import org.waveprotocol.wave.model.document.operation.impl.DocOpUtil 13 | 14 | import scala.util.parsing.input.CharSequenceReader 15 | 16 | import util._ 17 | 18 | class SwankProtocol(socket: Socket) extends Actor { 19 | import Actor._ 20 | import ClientFileActor._ 21 | import SExp._ 22 | import SwankProtocol._ 23 | 24 | val agent = new AsyncSocketAgent(socket, receiveData, { msg => 25 | println("ERROR!!! " + msg) 26 | System.exit(-1) 27 | }) 28 | 29 | @volatile 30 | var channel: ActorRef = _ 31 | 32 | @volatile 33 | var files = Map[String, ActorRef]() 34 | 35 | def receive = { 36 | case InitConnection(protocol, host, port) => { 37 | println("Initializing connection: %s://%s:%d".format(protocol, host, port)) 38 | channel = actorOf(new ServerChannel(protocol, host, port)).start() 39 | } 40 | 41 | case LinkFile(id, fileName) => { 42 | if (channel != null) { 43 | println("Linking file: %s -> %s".format(id, fileName)) 44 | files = files.updated(fileName, actorOf(new ClientFileActor(id, fileName, self, channel)).start()) 45 | } 46 | } 47 | 48 | case UnlinkFile(fileName) => { 49 | files get fileName foreach { _ ! PoisonPill } 50 | files -= fileName 51 | } 52 | 53 | case EditFile(fileName, op) => { 54 | val writer = new StringWriter 55 | OpFormat.write(Vector(op), writer) 56 | println(">>> Client Op: " + writer.toString) 57 | 58 | files get fileName foreach { _ ! op } 59 | } 60 | 61 | case EditPerformed(fileName, op) => { 62 | val writer = new StringWriter 63 | OpFormat.write(Vector(op), writer) 64 | println("<<< Server Op: " + writer.toString) 65 | 66 | dispatchSExp(SExp(key(":edit-performed"), fileName, marshallOp(op))) 67 | } 68 | } 69 | 70 | def receiveData(chunk: String) { 71 | println("Handling chunk: " + chunk) 72 | 73 | SExp.read(new CharSequenceReader(chunk)) match { 74 | case SExpList(KeywordAtom(":swank-rpc") :: (form @ SExpList(SymbolAtom(name) :: _)) :: IntAtom(callId) :: _) => { 75 | try { 76 | handleRPC(name, form, callId) 77 | } catch { 78 | case t => sendRPCError(ErrExceptionInRPC, t.getMessage, callId) 79 | } 80 | } 81 | 82 | case _ => sendProtocolError(ErrUnrecognizedForm, chunk) 83 | } 84 | } 85 | 86 | def handleRPC(name: String, form: SExp, callId: Int) = name match { 87 | case "swank:init-connection" => form match { 88 | case SExpList(_ :: (conf: SExpList) :: Nil) => { 89 | val map = conf.toKeywordMap 90 | 91 | if (map.contains(key(":host")) && map.contains(key(":port"))) { 92 | val StringAtom(protocol) = map.getOrElse(key(":protocol"), StringAtom("http")) 93 | val StringAtom(host) = map(key(":host")) 94 | val IntAtom(port) = map(key(":port")) 95 | 96 | self.start() ! InitConnection(protocol, host, port) 97 | } else { 98 | sendMalformedCall(name, form, callId) 99 | } 100 | } 101 | 102 | case _ => sendMalformedCall(name, form, callId) 103 | } 104 | 105 | case "swank:link-file" => form match { 106 | case SExpList(_ :: StringAtom(id) :: StringAtom(fileName) :: Nil) => 107 | self ! LinkFile(id, fileName) 108 | 109 | case _ => sendMalformedCall(name, form, callId) 110 | } 111 | 112 | case "swank:unlink-file" => form match { 113 | case SExpList(_ :: StringAtom(fileName) :: Nil) => 114 | self ! UnlinkFile(fileName) 115 | 116 | case _ => sendMalformedCall(name, form, callId) 117 | } 118 | 119 | case "swank:edit-file" => form match { 120 | case SExpList(_ :: StringAtom(fileName) :: (opForm: SExpList) :: Nil) => 121 | self ! EditFile(fileName, parseOp(opForm.items.toList)) 122 | 123 | case _ => sendMalformedCall(name, form, callId) 124 | } 125 | 126 | case "swank:shutdown" => System.exit(0) 127 | 128 | // TODO more calls 129 | 130 | case _ => sendRPCError(ErrUnrecognizedRPC, "Unknown :swank-rpc call: " + form, callId) 131 | } 132 | 133 | def sendMalformedCall(callType: String, form: SExp, callId: Int) { 134 | sendRPCError(ErrMalformedRPC, "Malformed %s call: %s".format(callType, form), callId) 135 | } 136 | 137 | def sendRPCError(code: Int, detail: String, callId: Int) { 138 | dispatchSExp(SExp(key(":return"), SExp(key(":abort"), code, detail), callId)) 139 | } 140 | 141 | def sendProtocolError(code: Int, detail: String) { 142 | dispatchSExp(SExp(key(":reader-error"), code, detail)) 143 | } 144 | 145 | def parseOp(form: List[SExp], op: Op = Op(0)): Op = form match { 146 | case KeywordAtom(":retain") :: IntAtom(length) :: tail => 147 | parseOp(tail, op.retain(length)) 148 | 149 | case KeywordAtom(":insert") :: StringAtom(text) :: tail => 150 | parseOp(tail, op.chars(text)) 151 | 152 | case KeywordAtom(":delete") :: StringAtom(text) :: tail => 153 | parseOp(tail, op.delete(text)) 154 | 155 | case Nil => op 156 | } 157 | 158 | def marshallOp(op: Op): SExp = { 159 | import DocOpComponentType._ 160 | 161 | val items = (0 until op.delta.size map op.delta.getType zipWithIndex) flatMap { 162 | case (RETAIN, i) => key(":retain") :: IntAtom(op.delta getRetainItemCount i) :: Nil 163 | case (CHARACTERS, i) => key(":insert") :: StringAtom(op.delta getCharactersString i) :: Nil 164 | case (DELETE_CHARACTERS, i) => key(":delete") :: StringAtom(op.delta getDeleteCharactersString i) :: Nil 165 | case (tpe, _) => throw new IllegalArgumentException("unknown op component: " + tpe) 166 | } 167 | 168 | SExpList(items) 169 | } 170 | 171 | def dispatchReturn(callId: Int, form: SExp) { 172 | dispatchSExp(SExp(key(":return"), SExp(key(":ok"), form, callId))) 173 | } 174 | 175 | def dispatchSExp(form: SExp) { 176 | dispatchData(form.toWireString) 177 | } 178 | 179 | def dispatchData(chunk: String) { 180 | println("Sending chunk: " + chunk) 181 | agent.send(chunk) 182 | } 183 | } 184 | 185 | object SwankProtocol { 186 | val ErrExceptionInRPC = 201 187 | val ErrMalformedRPC = 202 188 | val ErrUnrecognizedForm = 203 189 | val ErrUnrecognizedRPC = 204 190 | 191 | case class InitConnection(protocol: String, host: String, port: Int) 192 | case class LinkFile(id: String, fileName: String) 193 | case class UnlinkFile(fileName: String) 194 | case class EditFile(fileName: String, op: Op) 195 | } 196 | -------------------------------------------------------------------------------- /agent/src/main/scala/com/codecommit/cccp/agent/state.scala: -------------------------------------------------------------------------------- 1 | package com.codecommit.cccp 2 | package agent 3 | 4 | import org.waveprotocol.wave.model.document.operation.algorithm.{Composer, Transformer} 5 | 6 | sealed trait ClientState { 7 | val version: Int 8 | 9 | def applyClient(op: Op): Action 10 | def applyServer(op: Op): Action 11 | } 12 | 13 | case class Synchronized(version: Int) extends ClientState { 14 | def applyClient(op: Op) = { 15 | val op2 = op.reparent(version) 16 | Send(op2, AwaitingConfirm(op2, version)) 17 | } 18 | 19 | def applyServer(op: Op) = Apply(op, Synchronized(op.version)) 20 | } 21 | 22 | case class AwaitingConfirm(outstanding: Op, version: Int) extends ClientState { 23 | def applyClient(op: Op) = 24 | Shift(AwaitingWithBuffer(outstanding, op.reparent(outstanding.version), version)) 25 | 26 | def applyServer(op: Op) = { 27 | if (op.id == outstanding.id) { 28 | Shift(Synchronized(op.version)) 29 | } else { 30 | val pair = Transformer.transform(outstanding.delta, op.delta) 31 | val (client, server) = (pair.clientOp, pair.serverOp) 32 | val outstanding2 = outstanding.copy(delta = client) 33 | Apply(op.copy(delta = server), AwaitingConfirm(outstanding2.reparent(op.version), op.version)) 34 | } 35 | } 36 | } 37 | 38 | case class AwaitingWithBuffer(outstanding: Op, buffer: Op, version: Int) extends ClientState { 39 | def applyClient(op: Op) = { 40 | val buffer2 = buffer.copy(id = op.id, version = buffer.version + 1, delta = Composer.compose(buffer.delta, op.delta)) 41 | Shift(AwaitingWithBuffer(outstanding, buffer2, version)) 42 | } 43 | 44 | def applyServer(op: Op) = { 45 | if (op.id == outstanding.id) { 46 | Send(buffer, AwaitingConfirm(buffer, op.version)) 47 | } else { 48 | val pair = Transformer.transform(outstanding.delta, op.delta) 49 | val (client, server) = (pair.clientOp, pair.serverOp) 50 | val outstanding2 = outstanding.copy(delta = client).reparent(op.version) 51 | 52 | val pair2 = Transformer.transform(buffer.delta, server) 53 | val (client2, server2) = (pair2.clientOp, pair2.serverOp) 54 | val buffer2 = buffer.copy(delta = client2).reparent(outstanding2.version) 55 | 56 | Apply(op.copy(delta = server2), AwaitingWithBuffer(outstanding2, buffer2, op.version)) 57 | } 58 | } 59 | } 60 | 61 | 62 | sealed trait Action 63 | 64 | case class Send(op: Op, state: ClientState) extends Action 65 | case class Apply(op: Op, state: ClientState) extends Action 66 | case class Shift(state: ClientState) extends Action 67 | -------------------------------------------------------------------------------- /agent/src/main/scala/com/codecommit/cccp/util/SExp.scala: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2010, Aemon Cannon 3 | * All rights reserved. 4 | * 5 | * Redistribution and use in source and binary forms, with or without 6 | * modification, are permitted provided that the following conditions are met: 7 | * * Redistributions of source code must retain the above copyright 8 | * notice, this list of conditions and the following disclaimer. 9 | * * Redistributions in binary form must reproduce the above copyright 10 | * notice, this list of conditions and the following disclaimer in the 11 | * documentation and/or other materials provided with the distribution. 12 | * * Neither the name of ENSIME nor the 13 | * names of its contributors may be used to endorse or promote products 14 | * derived from this software without specific prior written permission. 15 | * 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 17 | * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 18 | * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | * DISCLAIMED. IN NO EVENT SHALL Aemon Cannon BE LIABLE FOR ANY 20 | * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 21 | * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 23 | * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 24 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 25 | * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | */ 27 | 28 | package com.codecommit.cccp 29 | package util 30 | 31 | import scala.collection.immutable.Map 32 | import scala.util.parsing.combinator._ 33 | import scala.util.parsing.input._ 34 | 35 | abstract class SExp { 36 | def toReadableString: String = toString 37 | def toWireString: String = toReadableString 38 | def toScala: Any = toString 39 | } 40 | 41 | case class SExpList(items: Iterable[SExp]) extends SExp with Iterable[SExp] { 42 | 43 | override def iterator = items.iterator 44 | 45 | override def toString = "(" + items.mkString(" ") + ")" 46 | 47 | override def toReadableString = { 48 | "(" + items.map { _.toReadableString }.mkString(" ") + ")" 49 | } 50 | 51 | def toKeywordMap(): Map[KeywordAtom, SExp] = { 52 | var m = Map[KeywordAtom, SExp]() 53 | items.sliding(2, 2).foreach { 54 | case (key: KeywordAtom) ::(sexp: SExp) :: rest => { 55 | m += (key -> sexp) 56 | } 57 | case _ => {} 58 | } 59 | m 60 | } 61 | 62 | def toSymbolMap(): Map[scala.Symbol, Any] = { 63 | var m = Map[scala.Symbol, Any]() 64 | items.sliding(2, 2).foreach { 65 | case SymbolAtom(key) ::(sexp: SExp) :: rest => { 66 | m += (Symbol(key) -> sexp.toScala) 67 | } 68 | case _ => {} 69 | } 70 | m 71 | } 72 | } 73 | 74 | object BooleanAtom { 75 | 76 | def unapply(z: SExp): Option[Boolean] = z match { 77 | case TruthAtom() => Some(true) 78 | case NilAtom() => Some(false) 79 | case _ => None 80 | } 81 | 82 | } 83 | 84 | abstract class BooleanAtom extends SExp { 85 | def toBool: Boolean 86 | override def toScala = toBool 87 | } 88 | 89 | case class NilAtom() extends BooleanAtom { 90 | override def toString = "nil" 91 | override def toBool: Boolean = false 92 | 93 | } 94 | case class TruthAtom() extends BooleanAtom { 95 | override def toString = "t" 96 | override def toBool: Boolean = true 97 | override def toScala: Boolean = true 98 | } 99 | case class StringAtom(value: String) extends SExp { 100 | override def toString = value 101 | override def toReadableString = { 102 | val printable = value.replace("\\", "\\\\").replace("\"", "\\\""); 103 | "\"" + printable + "\"" 104 | } 105 | } 106 | case class IntAtom(value: Int) extends SExp { 107 | override def toString = String.valueOf(value) 108 | override def toScala = value 109 | } 110 | case class SymbolAtom(value: String) extends SExp { 111 | override def toString = value 112 | } 113 | case class KeywordAtom(value: String) extends SExp { 114 | override def toString = value 115 | } 116 | 117 | object SExp extends RegexParsers { 118 | 119 | import scala.util.matching.Regex 120 | 121 | override val whiteSpace = """(\s+|;.*)+""".r 122 | 123 | lazy val string = regexGroups("""\"((?:[^\"\\]|\\.)*)\"""".r) ^^ { m => 124 | StringAtom(m.group(1).replace("\\\\", "\\")) 125 | } 126 | lazy val sym = regex("[a-zA-Z][a-zA-Z0-9-:]*".r) ^^ { s => 127 | if(s == "nil") NilAtom() 128 | else if(s == "t") TruthAtom() 129 | else SymbolAtom(s) 130 | } 131 | lazy val keyword = regex(":[a-zA-Z][a-zA-Z0-9-:]*".r) ^^ KeywordAtom 132 | lazy val number = regex("-?[0-9]+".r) ^^ { s => IntAtom(s.toInt) } 133 | lazy val list = literal("(") ~> rep(expr) <~ literal(")") ^^ SExpList.apply 134 | lazy val expr: Parser[SExp] = list | keyword | string | number | sym 135 | 136 | def read(r: Reader[Char]): SExp = { 137 | val result: ParseResult[SExp] = expr(r) 138 | result match { 139 | case Success(value, next) => value 140 | case Failure(errMsg, next) => { 141 | println(errMsg) 142 | NilAtom() 143 | } 144 | case Error(errMsg, next) => { 145 | println(errMsg) 146 | NilAtom() 147 | } 148 | } 149 | } 150 | 151 | /** A parser that matches a regex string and returns the match groups */ 152 | def regexGroups(r: Regex): Parser[Regex.Match] = new Parser[Regex.Match] { 153 | def apply(in: Input) = { 154 | val source = in.source 155 | val offset = in.offset 156 | val start = handleWhiteSpace(source, offset) 157 | (r findPrefixMatchOf (source.subSequence(start, source.length))) match { 158 | case Some(matched) => Success(matched, in.drop(start + matched.end - offset)) 159 | case None => 160 | Failure("string matching regex `" + r + 161 | "' expected but `" + 162 | in.first + "' found", in.drop(start - offset)) 163 | } 164 | } 165 | } 166 | 167 | def apply(items: SExp*): SExpList = { 168 | SExpList(items) 169 | } 170 | 171 | def apply(items: Iterable[SExp]): SExpList = { 172 | SExpList(items) 173 | } 174 | 175 | // Helpers for common case of key,val prop-list. 176 | // Omit keys for nil values. 177 | def propList(items: (String, SExp)*): SExpList = { 178 | propList(items) 179 | } 180 | def propList(items: Iterable[(String, SExp)]): SExpList = { 181 | val nonNil = items.filter { 182 | case (s, NilAtom()) => false 183 | case (s, SExpList(items)) if items.isEmpty => false 184 | case _ => true 185 | } 186 | SExpList(nonNil.flatMap(ea => List(key(ea._1), ea._2))) 187 | } 188 | 189 | implicit def strToSExp(str: String): SExp = { 190 | StringAtom(str) 191 | } 192 | 193 | def key(str: String): KeywordAtom = { 194 | KeywordAtom(str) 195 | } 196 | 197 | implicit def intToSExp(value: Int): SExp = { 198 | IntAtom(value) 199 | } 200 | 201 | implicit def boolToSExp(value: Boolean): SExp = { 202 | if (value) { 203 | TruthAtom() 204 | } else { 205 | NilAtom() 206 | } 207 | } 208 | 209 | implicit def symbolToSExp(value: Symbol): SExp = { 210 | if (value == 'nil) { 211 | NilAtom() 212 | } else { 213 | SymbolAtom(value.toString.drop(1)) 214 | } 215 | } 216 | 217 | implicit def nilToSExpList(nil: NilAtom): SExp = { 218 | SExpList(List()) 219 | } 220 | 221 | implicit def toSExp(o: SExpable): SExp = { 222 | o.toSExp 223 | } 224 | 225 | implicit def toSExpable(o: SExp): SExpable = new SExpable { 226 | def toSExp = o 227 | } 228 | 229 | implicit def listToSExpable(o: Iterable[SExpable]): SExpable = new Iterable[SExpable] with SExpable { 230 | override def iterator = o.iterator 231 | override def toSExp = SExp(o.map { _.toSExp }) 232 | } 233 | 234 | } 235 | 236 | abstract trait SExpable { 237 | implicit def toSExp(): SExp 238 | } 239 | 240 | -------------------------------------------------------------------------------- /agent/src/test/scala/com/codecommit/cccp/agent/ClientStateSpecs.scala: -------------------------------------------------------------------------------- 1 | package com.codecommit.cccp 2 | package agent 3 | 4 | import org.specs2.mutable.Specification 5 | 6 | object ClientStateSpecs extends Specification { 7 | 8 | "client state" should { 9 | "send client ops immediately when synchronized" in { 10 | val state = Synchronized(0) 11 | state.applyClient(Op(0).chars("test")) must beLike { 12 | case Send(op, _) => { 13 | op.parent mustEqual 0 14 | op.version mustEqual 1 15 | 16 | op.delta.size mustEqual 1 17 | op.delta.getCharactersString(0) mustEqual "test" 18 | } 19 | } 20 | } 21 | 22 | "apply server ops immediately when synchronized" in { 23 | val state = Synchronized(0) 24 | state.applyServer(Op(0).chars("test")) must beLike { 25 | case Apply(op, _) => { 26 | op.parent mustEqual 0 27 | op.version mustEqual 1 28 | 29 | op.delta.size mustEqual 1 30 | op.delta.getCharactersString(0) mustEqual "test" 31 | } 32 | } 33 | } 34 | 35 | "buffer client ops when return is outstanding" in { 36 | var state: ClientState = Synchronized(0) 37 | val op = Op(0).chars("test") 38 | 39 | state.applyClient(op) must beLike { 40 | case Send(`op`, state2 @ AwaitingConfirm(`op`, 0)) => { 41 | state = state2 42 | ok 43 | } 44 | } 45 | 46 | state.applyClient(Op(0).retain(4).chars("ing")) must beLike { 47 | case Shift(state2 @ AwaitingWithBuffer(`op`, _, 0)) => { 48 | state = state2 49 | ok 50 | } 51 | } 52 | 53 | state.applyClient(Op(0).retain(7).chars("!")) must beLike { 54 | case Shift(state2 @ AwaitingWithBuffer(`op`, _, 0)) => { 55 | state = state2 56 | ok 57 | } 58 | } 59 | } 60 | 61 | "flush client buffer on return" in { 62 | var state: ClientState = Synchronized(0) 63 | val op = Op(0).chars("test") 64 | 65 | state.applyClient(op) must beLike { 66 | case Send(`op`, state2 @ AwaitingConfirm(`op`, 0)) => { 67 | state = state2 68 | ok 69 | } 70 | } 71 | 72 | state.applyClient(Op(0).retain(4).chars("ing")) must beLike { 73 | case Shift(state2 @ AwaitingWithBuffer(`op`, _, 0)) => { 74 | state = state2 75 | ok 76 | } 77 | } 78 | 79 | state.applyClient(Op(0).retain(7).chars("!")) must beLike { 80 | case Shift(state2 @ AwaitingWithBuffer(`op`, _, 0)) => { 81 | state = state2 82 | ok 83 | } 84 | } 85 | 86 | var sent: Op = null 87 | state.applyServer(op) must beLike { 88 | case Send(op, state2 @ AwaitingConfirm(op2, 1)) if op == op2 => { 89 | state = state2 90 | 91 | sent = op 92 | op.parent mustEqual 1 93 | op.version mustEqual 3 94 | 95 | op.delta.size mustEqual 2 96 | op.delta.getRetainItemCount(0) mustEqual 4 97 | op.delta.getCharactersString(1) mustEqual "ing!" 98 | } 99 | } 100 | 101 | state.applyClient(Op(0).retain(8).chars(" 1 2 3")) must beLike { 102 | case Shift(state2 @ AwaitingWithBuffer(op2, _, 1)) if op2 == sent => { 103 | state = state2 104 | ok 105 | } 106 | } 107 | 108 | state.applyServer(sent) must beLike { 109 | case Send(op, state2 @ AwaitingConfirm(op2, 3)) if op == op2 => { 110 | state = state2 111 | sent = op 112 | 113 | op.parent mustEqual 3 114 | op.version mustEqual 4 115 | 116 | op.delta.size mustEqual 2 117 | op.delta.getRetainItemCount(0) mustEqual 8 118 | op.delta.getCharactersString(1) mustEqual " 1 2 3" 119 | } 120 | } 121 | 122 | state.applyServer(sent) must beLike { 123 | case Shift(state2 @ Synchronized(4)) => { 124 | state = state2 125 | ok 126 | } 127 | } 128 | } 129 | 130 | "transform client buffer on server op" in { 131 | var state: ClientState = Synchronized(0) 132 | val op = Op(0).chars("test") 133 | 134 | state.applyClient(op) must beLike { 135 | case Send(_, state2 @ AwaitingConfirm(`op`, 0)) => { 136 | state = state2 137 | ok 138 | } 139 | } 140 | 141 | state.applyClient(Op(0).retain(4).chars("ing")) must beLike { 142 | case Shift(state2 @ AwaitingWithBuffer(`op`, _, 0)) => { 143 | state = state2 144 | ok 145 | } 146 | } 147 | 148 | state.applyClient(Op(0).retain(7).chars("!")) must beLike { 149 | case Shift(state2 @ AwaitingWithBuffer(`op`, _, 0)) => { 150 | state = state2 151 | ok 152 | } 153 | } 154 | 155 | state.applyServer(Op(0).chars(" isn't that swell?")) must beLike { 156 | case Apply(serverOp, state2 @ AwaitingWithBuffer(op2, buffer, 1)) => { 157 | state = state2 158 | 159 | serverOp.parent mustEqual 0 160 | serverOp.version mustEqual 1 161 | 162 | serverOp.delta.size mustEqual 2 163 | serverOp.delta.getRetainItemCount(0) mustEqual 8 164 | serverOp.delta.getCharactersString(1) mustEqual " isn't that swell?" 165 | 166 | op2.parent mustEqual 1 167 | op2.version mustEqual 2 168 | 169 | op2.delta.size mustEqual 2 170 | op2.delta.getCharactersString(0) mustEqual "test" 171 | op2.delta.getRetainItemCount(1) mustEqual 18 172 | 173 | buffer.parent mustEqual 2 174 | buffer.version mustEqual 4 175 | 176 | buffer.delta.size mustEqual 3 177 | buffer.delta.getRetainItemCount(0) mustEqual 4 178 | buffer.delta.getCharactersString(1) mustEqual "ing!" 179 | op2.delta.getRetainItemCount(1) mustEqual 18 180 | } 181 | } 182 | } 183 | 184 | "update version of second operation from non-initial synchronization" in { 185 | var state: ClientState = Synchronized(31) 186 | var sent: Op = null 187 | 188 | state.applyClient(Op(0).retain(467).chars("\n").retain(1536)) must beLike { 189 | case Send(op, state2 @ AwaitingConfirm(op2, 31)) => { 190 | state = state2 191 | sent = op 192 | 193 | op.parent mustEqual 31 194 | op.version mustEqual 32 195 | 196 | op2.parent mustEqual 31 197 | op.version mustEqual 32 198 | } 199 | } 200 | 201 | state.applyClient(Op(0).retain(468).chars(" ").retain(1536)) must beLike { 202 | case Shift(state2 @ AwaitingWithBuffer(outstanding, buffer, 31)) => { 203 | state = state2 204 | 205 | outstanding.parent mustEqual 31 206 | outstanding.version mustEqual 32 207 | 208 | buffer.parent mustEqual 32 209 | buffer.version mustEqual 33 210 | } 211 | } 212 | 213 | state.applyServer(sent) must beLike { 214 | case Send(op, state2 @ AwaitingConfirm(op2, 32)) => { 215 | state = state2 216 | sent = op 217 | 218 | op.parent mustEqual 32 219 | op.version mustEqual 33 220 | 221 | op2.parent mustEqual 32 222 | op2.version mustEqual 33 223 | } 224 | } 225 | 226 | state.applyServer(sent) must beLike { 227 | case Shift(state2 @ Synchronized(33)) => { 228 | state = state2 229 | sent = null 230 | ok 231 | } 232 | } 233 | } 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | scalaVersion := "2.9.1" 2 | 3 | version := "0.1-SNAPSHOT" 4 | 5 | organization := "com.codecommit" 6 | 7 | name := "cccp" 8 | -------------------------------------------------------------------------------- /clients/jedit/build.sbt: -------------------------------------------------------------------------------- 1 | import IO._ 2 | 3 | name := "cccp-jedit-client" 4 | 5 | unmanagedJars in Compile += { 6 | var jedit = new File(System.getenv("JEDIT_HOME") + "/jedit.jar") 7 | if (!jedit.exists) jedit = new File("/Applications/jEdit.app/Contents/Resources/Java/jedit.jar") 8 | if (!jedit.exists) jedit = new File("c:/Program Files/jEdit/jedit.jar") 9 | if (!jedit.exists) sys.error("jedit.jar was not found. please, set the JEDIT_HOME environment variable") 10 | Attributed.blank(jedit) 11 | } 12 | 13 | exportJars := true 14 | 15 | stage <<= (dependencyClasspath in Runtime, exportedProducts in Runtime) map { (depCP, exportedCP) => 16 | // this task "borrowed" from ENSIME (thanks, Aemon!) 17 | val jedit = Path("clients/jedit") 18 | val log = LogManager.defaultScreen 19 | delete(file("dist")) 20 | log.info("Copying runtime environment to ./dist....") 21 | createDirectories(List( 22 | file("clients/jedit/dist"), 23 | file("clients/jedit/dist/lib"))) 24 | // Copy the runtime jars 25 | val deps = (depCP ++ exportedCP).map(_.data) 26 | copy(deps x flat(jedit / "dist" / "lib")) 27 | } 28 | -------------------------------------------------------------------------------- /clients/jedit/local-deploy.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | if not defined JEDIT_HOME set JEDIT_HOME=c:\Program Files\jEdit 4 | if not exist "%JEDIT_HOME%" set JEDIT_HOME=%1 5 | 6 | if not exist "%JEDIT_HOME%" ( 7 | echo 'Must specify a valid destination for jEdit plugin JARs!' 8 | exit -1 9 | ) 10 | 11 | xcopy "%~dp0\dist\lib\cccp-jedit-client_2.9.1-0.1.jar" "%JEDIT_HOME%\jars\CCCP.jar" /y 12 | xcopy "%~dp0\dist\lib\scala-library.jar" "%JEDIT_HOME%\jars" /y 13 | -------------------------------------------------------------------------------- /clients/jedit/local-deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | JEDIT_HOME=~/Library/jEdit 4 | [ ! -d $JEDIT_HOME ] && JEDIT_HOME=$1 5 | 6 | if [ ! -d $JEDIT_HOME ]; then 7 | echo 'Must specify a valid destination for jEdit plugin JARs!' 8 | exit -1 9 | fi 10 | 11 | cp dist/lib/cccp-jedit-client_2.9.1-0.1.jar $JEDIT_HOME/jars/CCCP.jar 12 | cp dist/lib/scala-library.jar $JEDIT_HOME/jars/scala-library.jar 13 | -------------------------------------------------------------------------------- /clients/jedit/src/main/resources/actions.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | com.codecommit.cccp.jedit.CCCPPlugin.link(view); 7 | 8 | 9 | 10 | 11 | com.codecommit.cccp.jedit.CCCPPlugin.unlink(view); 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /clients/jedit/src/main/resources/plugin.props: -------------------------------------------------------------------------------- 1 | plugin.com.codecommit.cccp.jedit.CCCPPlugin.activate=defer 2 | plugin.com.codecommit.cccp.jedit.CCCPPlugin.name=CCCP 3 | plugin.com.codecommit.cccp.jedit.CCCPPlugin.author=Daniel Spiewak 4 | plugin.com.codecommit.cccp.jedit.CCCPPlugin.version=0.1 5 | plugin.com.codecommit.cccp.jedit.CCCPPlugin.jars=scala-library.jar 6 | 7 | plugin.com.codecommit.cccp.jedit.CCCPPlugin.option-group=cccp 8 | 9 | options.cccp.label=CCCP 10 | options.cccp.code=new com.codecommit.cccp.jedit.CCCPOptionPane(); 11 | 12 | plugin.com.codecommit.cccp.jedit.CCCPPlugin.menu=cccp.link \ 13 | cccp.unlink 14 | 15 | options.menu.label=CCCP 16 | 17 | # CCCP 18 | cccp.link.label=Link File... 19 | cccp.unlink.label=Unlink File 20 | -------------------------------------------------------------------------------- /clients/jedit/src/main/scala/com/codecommit/cccp/jedit/AsyncSocketAgent.scala: -------------------------------------------------------------------------------- 1 | // TODO merge with the agent version of this file in a common project 2 | 3 | package com.codecommit.cccp 4 | package jedit 5 | 6 | import java.io.{BufferedWriter, InputStreamReader, OutputStreamWriter, Reader} 7 | import java.net.Socket 8 | import java.util.concurrent.{LinkedBlockingQueue, TimeUnit} 9 | import scala.collection.mutable.ArrayBuffer 10 | 11 | class AsyncSocketAgent(val socket: Socket, callback: String => Unit, failure: String => Unit) { self => 12 | private val queue = new LinkedBlockingQueue[String] 13 | 14 | private val writerThread = { 15 | val back = new Thread { 16 | override def run() { 17 | try { 18 | self.runWriter() 19 | } catch { 20 | case e => { 21 | failure("%s: %s".format(e.getClass.getSimpleName, e.toString)) 22 | throw e 23 | } 24 | } 25 | } 26 | } 27 | back.setPriority(3) 28 | back.start() 29 | back 30 | } 31 | 32 | private val readerThread = { 33 | val back = new Thread { 34 | override def run() { 35 | try { 36 | self.runReader() 37 | } catch { 38 | case e => { 39 | failure("%s: %s".format(e.getClass.getSimpleName, e.toString)) 40 | throw e 41 | } 42 | } 43 | } 44 | } 45 | back.setPriority(3) 46 | back.start() 47 | back 48 | } 49 | 50 | private var stopRequested = false 51 | 52 | def send(chunk: String) { 53 | queue.offer(chunk) 54 | } 55 | 56 | def stop() { 57 | stopRequested = true 58 | } 59 | 60 | private def runWriter() { 61 | val writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream)) 62 | 63 | try { 64 | while (!stopRequested) { 65 | val work = queue.poll(1, TimeUnit.SECONDS) 66 | if (work != null) { 67 | writer.write("%06x" format work.length) 68 | writer.write(work) 69 | writer.flush() 70 | } 71 | } 72 | } catch { case _ if stopRequested => } 73 | } 74 | 75 | private def runReader() { 76 | val reader = new InputStreamReader(socket.getInputStream) 77 | 78 | try { 79 | while (!stopRequested) { 80 | val totalBuffer = new ArrayBuffer[Char] 81 | var remaining = readHeader(reader) 82 | 83 | while (remaining != 0) { 84 | val buffer = new Array[Char](remaining) 85 | remaining -= reader.read(buffer) 86 | totalBuffer ++= buffer 87 | } 88 | 89 | callback(new String(totalBuffer.toArray)) 90 | } 91 | } catch { case _ if stopRequested => } 92 | } 93 | 94 | private def readHeader(reader: Reader) = { 95 | val header = new Array[Char](6) 96 | reader.read(header) 97 | Integer.valueOf(new String(header), 16) 98 | } 99 | } 100 | 101 | object AsyncSocketAgent { 102 | def sync[A](timeout: Long)(f: (A => Unit) => Unit): Option[A] = { 103 | var result: Option[A] = None 104 | val signal = new AnyRef 105 | 106 | f { asyncRes => 107 | signal synchronized { 108 | result = Some(asyncRes) 109 | signal.notifyAll() 110 | } 111 | } 112 | 113 | signal synchronized { 114 | if (result.isEmpty) { 115 | signal.wait(timeout) 116 | } 117 | } 118 | 119 | result 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /clients/jedit/src/main/scala/com/codecommit/cccp/jedit/Backend.scala: -------------------------------------------------------------------------------- 1 | package com.codecommit.cccp 2 | package jedit 3 | 4 | import java.io.{File, FileOutputStream, InputStream, OutputStream} 5 | import java.net.Socket 6 | 7 | import scala.io.Source 8 | 9 | class Backend(home: File, fatalServerError: String => Unit) { 10 | val IsWindows = System.getProperty("os.name").toLowerCase().contains("windows") 11 | val TempDir = if (IsWindows) new File(System.getProperty("java.io.tmpdir")) else new File("/tmp") 12 | 13 | private var isStarted = false 14 | 15 | private var proc: Process = _ 16 | private var portFile: File = _ // ouch! 17 | private var port: Int = -1 18 | 19 | private var stderrCopier: Thread = _ 20 | private var stdoutCopier: Thread = _ 21 | 22 | private var agent: AsyncSocketAgent = _ 23 | 24 | def start(env: (String, String)*)(callback: String => Unit) { 25 | if (!isStarted) { 26 | portFile = File.createTempFile("cccp", ".port", TempDir) 27 | val logFile = File.createTempFile("cccp", ".log", TempDir) 28 | 29 | val serverScript = new File(new File(home, "bin"), if (IsWindows) "server.bat" else "server") 30 | 31 | val builder = new ProcessBuilder(serverScript.getAbsolutePath, portFile.getCanonicalPath) 32 | 33 | for ((k, v) <- env) { 34 | builder.environment.put(k, v) 35 | } 36 | 37 | builder.directory(home) 38 | proc = builder.start() 39 | 40 | val fos = new FileOutputStream(logFile) 41 | stderrCopier = ioCopier(proc.getErrorStream, fos) 42 | stdoutCopier = ioCopier(proc.getInputStream, fos) 43 | 44 | stderrCopier.start() 45 | stdoutCopier.start() 46 | 47 | // busy-wait until port is written 48 | while (port < 0) { 49 | val src = Source fromFile portFile 50 | src.getLines map { _.toInt } foreach { port = _ } 51 | src.close() 52 | } 53 | 54 | agent = new AsyncSocketAgent(new Socket("localhost", port), callback, fatalServerError) 55 | isStarted = true 56 | } 57 | } 58 | 59 | def send(chunk: String) { 60 | agent.send(chunk) 61 | } 62 | 63 | def stop() { 64 | agent.stop() 65 | agent.socket.close() 66 | 67 | stderrCopier.interrupt() 68 | stdoutCopier.interrupt() 69 | proc.destroy() 70 | } 71 | 72 | def ioCopier(is: InputStream, os: OutputStream): Thread = new Thread { 73 | setDaemon(true) 74 | setPriority(1) 75 | 76 | override def run() { 77 | try { 78 | var b = is.read() 79 | while (b >= 0) { 80 | os.write(b) 81 | b = is.read() 82 | } 83 | } catch { 84 | case _ => try { os.close() } catch { case _ => } 85 | } 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /clients/jedit/src/main/scala/com/codecommit/cccp/jedit/CCCPOptionPane.scala: -------------------------------------------------------------------------------- 1 | package com.codecommit.cccp 2 | package jedit 3 | 4 | import java.awt.BorderLayout 5 | import java.awt.event.{ActionEvent, ActionListener} 6 | import javax.swing._ 7 | import org.gjt.sp.jedit 8 | import org.gjt.sp.jedit.{AbstractOptionPane, jEdit => JEdit} 9 | import org.gjt.sp.jedit.browser.VFSFileChooserDialog 10 | 11 | class CCCPOptionPane extends AbstractOptionPane("cccp") { 12 | private val homeField = new JTextField(CCCPPlugin.Home.getCanonicalPath) 13 | private val protocolField = new JTextField(CCCPPlugin.Protocol) 14 | private val hostField = new JTextField(CCCPPlugin.Host) 15 | private val portField = new JTextField(CCCPPlugin.Port.toString) 16 | 17 | override def _init() { 18 | val homePanel = new JPanel(new BorderLayout) 19 | addComponent("CCCP Agent Home", homePanel) 20 | 21 | homePanel.add(homeField) 22 | 23 | val button = new JButton("...") 24 | button.addActionListener(new ActionListener { 25 | def actionPerformed(e: ActionEvent) { 26 | val view = JEdit.getActiveView // can we do without this? 27 | val dialog = new VFSFileChooserDialog(view, homeField.getText, 0, false, true) 28 | dialog.getSelectedFiles.headOption foreach homeField.setText 29 | } 30 | }) 31 | homePanel.add(button, BorderLayout.EAST) 32 | 33 | addComponent("Protocol", protocolField) 34 | addComponent("Host", hostField) 35 | addComponent("Port", portField) 36 | } 37 | 38 | override def _save() { 39 | JEdit.setProperty(CCCPPlugin.HomeProperty, homeField.getText) 40 | JEdit.setProperty(CCCPPlugin.ProtocolProperty, protocolField.getText) 41 | JEdit.setProperty(CCCPPlugin.HostProperty, hostField.getText) 42 | JEdit.setProperty(CCCPPlugin.PortProperty, portField.getText) 43 | 44 | CCCPPlugin.reinit() 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /clients/jedit/src/main/scala/com/codecommit/cccp/jedit/CCCPPlugin.scala: -------------------------------------------------------------------------------- 1 | package com.codecommit.cccp 2 | package jedit 3 | 4 | import java.awt.EventQueue 5 | import java.io.File 6 | import java.lang.ref.WeakReference 7 | import javax.swing.JOptionPane 8 | import org.gjt.sp.jedit 9 | import jedit.{Buffer, jEdit => JEdit, EBPlugin, View} 10 | import jedit.buffer.{BufferAdapter, JEditBuffer} 11 | import jedit.textarea.Selection 12 | import scala.util.parsing.input.CharSequenceReader 13 | 14 | class CCCPPlugin extends EBPlugin { 15 | override def start() { 16 | val starter = new Thread { 17 | override def run() { 18 | CCCPPlugin.init() 19 | } 20 | } 21 | 22 | starter.setPriority(3) 23 | starter.start() 24 | } 25 | 26 | override def stop() { 27 | CCCPPlugin.shutdown() 28 | } 29 | } 30 | 31 | object CCCPPlugin { 32 | import SExp._ 33 | 34 | val HomeProperty = "cccp.home" 35 | val ProtocolProperty = "cccp.protocol" 36 | val HostProperty = "cccp.host" 37 | val PortProperty = "cccp.port" 38 | 39 | def Home = new File(Option(JEdit.getProperty(HomeProperty)) getOrElse "/Users/daniel/Development/Scala/cccp/agent/dist/") 40 | var Backend = new Backend(Home, fatalServerError) 41 | 42 | def Protocol = Option(JEdit.getProperty(ProtocolProperty)) getOrElse "http" 43 | def Host = Option(JEdit.getProperty(HostProperty)) getOrElse "localhost" 44 | def Port = Option(JEdit.getProperty(PortProperty)) flatMap { s => try { Some(s.toInt) } catch { case _ => None } } getOrElse 8585 45 | 46 | @volatile 47 | private var _callId = 0 48 | private val callLock = new AnyRef 49 | 50 | @volatile 51 | private var ignoredFiles = Set[String]() // will only be written from the EDT 52 | 53 | def callId() = callLock synchronized { 54 | _callId += 1 55 | _callId 56 | } 57 | 58 | // TODO make this more controlled 59 | def reinit() { 60 | shutdown() 61 | Backend = new Backend(Home, fatalServerError) 62 | init() 63 | } 64 | 65 | private def init() { 66 | Backend.start()(receive) 67 | sendRPC(SExp(key("swank:init-connection"), SExp(key(":protocol"), Protocol, key(":host"), Host, key(":port"), Port)), callId()) 68 | } 69 | 70 | private def shutdown() { 71 | sendRPC(SExp(key("swank:shutdown")), callId()) 72 | Backend.stop() 73 | } 74 | 75 | def link(view: View) { 76 | val buffer = view.getBuffer 77 | val fileName = new File(buffer.getPath).getAbsolutePath 78 | 79 | EventQueue.invokeLater(new Runnable { 80 | def run() { 81 | for (id <- Option(JOptionPane.showInputDialog(view, "File ID:"))) { 82 | sendRPC(SExp(key("swank:link-file"), id, fileName), callId()) 83 | 84 | buffer.addBufferListener(new BufferAdapter { 85 | override def contentInserted(editBuffer: JEditBuffer, startLine: Int, offset: Int, numLines: Int, length: Int) { 86 | contentChanged("insert", editBuffer, startLine, offset, numLines, length) 87 | } 88 | 89 | override def preContentRemoved(editBuffer: JEditBuffer, startLine: Int, offset: Int, numLines: Int, length: Int) { 90 | contentChanged("delete", editBuffer, startLine, offset, numLines, length) 91 | } 92 | 93 | private def contentChanged(change: String, editBuffer: JEditBuffer, startLine: Int, offset: Int, numLines: Int, length: Int) { 94 | editBuffer match { 95 | case buffer: Buffer => { 96 | if (fileName == new File(buffer.getPath).getAbsolutePath) { 97 | if (!ignoredFiles.contains(fileName)) { 98 | val text = buffer.getText(offset, length) 99 | val remainder = buffer.getLength - offset - text.length 100 | sendChange(change, fileName, offset, text, remainder) 101 | } 102 | } 103 | } 104 | 105 | case _ => // ignore 106 | } 107 | } 108 | }) 109 | } 110 | } 111 | }) 112 | } 113 | 114 | def unlink(view: View) { 115 | val buffer = view.getBuffer 116 | val fileName = new File(buffer.getPath).getAbsolutePath 117 | 118 | EventQueue.invokeLater(new Runnable { 119 | def run() { 120 | val confirm = JOptionPane.showConfirmDialog(view, "Are you sure you wish to unlink the buffer? You should not attempt to relink on the same identifier following an unlink.", "Are you sure?", JOptionPane.YES_NO_OPTION) 121 | 122 | if (confirm == JOptionPane.YES_OPTION) { 123 | sendRPC(SExp(key("swank:unlink-file"), fileName), callId()) 124 | } 125 | } 126 | }) 127 | } 128 | 129 | private def applyActions(fileName: String, actions: Seq[EditorAction]) { 130 | val view = JEdit.getActiveView 131 | val buffer = JEdit.openFile(view, fileName) 132 | 133 | val field = classOf[JEditBuffer].getDeclaredField("undoInProgress") 134 | field.setAccessible(true) 135 | field.set(buffer, true) 136 | 137 | buffer.beginCompoundEdit() 138 | try { 139 | actions foreach { 140 | case InsertAt(offset, text) => buffer.insert(offset, text) 141 | case DeleteAt(offset, text) => buffer.remove(offset, text.length) 142 | } 143 | } finally { 144 | buffer.endCompoundEdit() 145 | field.set(buffer, false) 146 | } 147 | 148 | // TODO adjust undo/redo stack for new offsets 149 | } 150 | 151 | // TODO type safety 152 | private def sendChange(change: String, fileName: String, offset: Int, text: String, after: Int) { 153 | val pre = if (offset > 0) key(":retain") :: IntAtom(offset) :: Nil else Nil 154 | val mid = key(":" + change) :: StringAtom(text) :: Nil 155 | val post = if (after > 0) key(":retain") :: IntAtom(after) :: Nil else Nil 156 | val op = SExpList(pre ::: mid ::: post) 157 | sendRPC(SExp(key("swank:edit-file"), fileName, op), callId()) 158 | } 159 | 160 | private def sendRPC(form: SExp, id: Int) { 161 | send(SExp(key(":swank-rpc"), form, id).toWireString) 162 | } 163 | 164 | private def send(chunk: String) { 165 | Backend.send(chunk) 166 | } 167 | 168 | private def receive(chunk: String) { 169 | SExp.read(new CharSequenceReader(chunk)) match { 170 | case SExpList(KeywordAtom(":edit-performed") :: StringAtom(fileName) :: (form: SExpList) :: Nil) => { 171 | val components = unmarshallOp(form.items.toList) 172 | 173 | val (_, actions) = components.foldLeft((0, Vector[EditorAction]())) { 174 | case ((offset, acc), Retain(length)) => (offset + length, acc) 175 | case ((offset, acc), Insert(text)) => (offset + text.length, acc :+ InsertAt(offset, text)) 176 | case ((offset, acc), Delete(text)) => (offset + text.length, acc :+ DeleteAt(offset, text)) 177 | } 178 | 179 | EventQueue.invokeLater(new Runnable { 180 | def run() { 181 | ignoredFiles += fileName 182 | applyActions(fileName, actions) 183 | ignoredFiles -= fileName 184 | } 185 | }) 186 | } 187 | 188 | case _ => // TODO 189 | } 190 | } 191 | 192 | private def fatalServerError(msg: String) { 193 | } 194 | 195 | private def unmarshallOp(form: List[SExp]): List[OpComponent] = { 196 | val components = form zip (form drop 1) collect { case (KeywordAtom(id), se) => (id, se) } 197 | components collect { 198 | case (":retain", IntAtom(length)) if length > 0 => Retain(length) 199 | case (":insert", StringAtom(text)) => Insert(text) 200 | case (":delete", StringAtom(text)) => Delete(text) 201 | } 202 | } 203 | 204 | sealed trait OpComponent 205 | case class Retain(length: Int) extends OpComponent 206 | case class Insert(text: String) extends OpComponent 207 | case class Delete(text: String) extends OpComponent 208 | 209 | sealed trait EditorAction { 210 | val offset: Int 211 | } 212 | 213 | case class InsertAt(offset: Int, text: String) extends EditorAction 214 | case class DeleteAt(offset: Int, text: String) extends EditorAction 215 | } 216 | -------------------------------------------------------------------------------- /clients/jedit/src/main/scala/com/codecommit/cccp/jedit/SExp.scala: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2010, Aemon Cannon 3 | * All rights reserved. 4 | * 5 | * Redistribution and use in source and binary forms, with or without 6 | * modification, are permitted provided that the following conditions are met: 7 | * * Redistributions of source code must retain the above copyright 8 | * notice, this list of conditions and the following disclaimer. 9 | * * Redistributions in binary form must reproduce the above copyright 10 | * notice, this list of conditions and the following disclaimer in the 11 | * documentation and/or other materials provided with the distribution. 12 | * * Neither the name of ENSIME nor the 13 | * names of its contributors may be used to endorse or promote products 14 | * derived from this software without specific prior written permission. 15 | * 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 17 | * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 18 | * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | * DISCLAIMED. IN NO EVENT SHALL Aemon Cannon BE LIABLE FOR ANY 20 | * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 21 | * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 23 | * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 24 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 25 | * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | */ 27 | 28 | // TODO extract to common 29 | package com.codecommit.cccp 30 | package jedit 31 | 32 | import scala.collection.immutable.Map 33 | import scala.util.parsing.combinator._ 34 | import scala.util.parsing.input._ 35 | 36 | abstract class SExp { 37 | def toReadableString: String = toString 38 | def toWireString: String = toReadableString 39 | def toScala: Any = toString 40 | } 41 | 42 | case class SExpList(items: Iterable[SExp]) extends SExp with Iterable[SExp] { 43 | 44 | override def iterator = items.iterator 45 | 46 | override def toString = "(" + items.mkString(" ") + ")" 47 | 48 | override def toReadableString = { 49 | "(" + items.map { _.toReadableString }.mkString(" ") + ")" 50 | } 51 | 52 | def toKeywordMap(): Map[KeywordAtom, SExp] = { 53 | var m = Map[KeywordAtom, SExp]() 54 | items.sliding(2, 2).foreach { 55 | case (key: KeywordAtom) ::(sexp: SExp) :: rest => { 56 | m += (key -> sexp) 57 | } 58 | case _ => {} 59 | } 60 | m 61 | } 62 | 63 | def toSymbolMap(): Map[scala.Symbol, Any] = { 64 | var m = Map[scala.Symbol, Any]() 65 | items.sliding(2, 2).foreach { 66 | case SymbolAtom(key) ::(sexp: SExp) :: rest => { 67 | m += (Symbol(key) -> sexp.toScala) 68 | } 69 | case _ => {} 70 | } 71 | m 72 | } 73 | } 74 | 75 | object BooleanAtom { 76 | 77 | def unapply(z: SExp): Option[Boolean] = z match { 78 | case TruthAtom() => Some(true) 79 | case NilAtom() => Some(false) 80 | case _ => None 81 | } 82 | 83 | } 84 | 85 | abstract class BooleanAtom extends SExp { 86 | def toBool: Boolean 87 | override def toScala = toBool 88 | } 89 | 90 | case class NilAtom() extends BooleanAtom { 91 | override def toString = "nil" 92 | override def toBool: Boolean = false 93 | 94 | } 95 | case class TruthAtom() extends BooleanAtom { 96 | override def toString = "t" 97 | override def toBool: Boolean = true 98 | override def toScala: Boolean = true 99 | } 100 | case class StringAtom(value: String) extends SExp { 101 | override def toString = value 102 | override def toReadableString = { 103 | val printable = value.replace("\\", "\\\\").replace("\"", "\\\""); 104 | "\"" + printable + "\"" 105 | } 106 | } 107 | case class IntAtom(value: Int) extends SExp { 108 | override def toString = String.valueOf(value) 109 | override def toScala = value 110 | } 111 | case class SymbolAtom(value: String) extends SExp { 112 | override def toString = value 113 | } 114 | case class KeywordAtom(value: String) extends SExp { 115 | override def toString = value 116 | } 117 | 118 | object SExp extends RegexParsers { 119 | 120 | import scala.util.matching.Regex 121 | 122 | override val whiteSpace = """(\s+|;.*)+""".r 123 | 124 | lazy val string = regexGroups("""\"((?:[^\"\\]|\\.)*)\"""".r) ^^ { m => 125 | StringAtom(m.group(1).replace("\\\\", "\\")) 126 | } 127 | lazy val sym = regex("[a-zA-Z][a-zA-Z0-9-:]*".r) ^^ { s => 128 | if(s == "nil") NilAtom() 129 | else if(s == "t") TruthAtom() 130 | else SymbolAtom(s) 131 | } 132 | lazy val keyword = regex(":[a-zA-Z][a-zA-Z0-9-:]*".r) ^^ KeywordAtom 133 | lazy val number = regex("-?[0-9]+".r) ^^ { s => IntAtom(s.toInt) } 134 | lazy val list = literal("(") ~> rep(expr) <~ literal(")") ^^ SExpList.apply 135 | lazy val expr: Parser[SExp] = list | keyword | string | number | sym 136 | 137 | def read(r: Reader[Char]): SExp = { 138 | val result: ParseResult[SExp] = expr(r) 139 | result match { 140 | case Success(value, next) => value 141 | case Failure(errMsg, next) => { 142 | println(errMsg) 143 | NilAtom() 144 | } 145 | case Error(errMsg, next) => { 146 | println(errMsg) 147 | NilAtom() 148 | } 149 | } 150 | } 151 | 152 | /** A parser that matches a regex string and returns the match groups */ 153 | def regexGroups(r: Regex): Parser[Regex.Match] = new Parser[Regex.Match] { 154 | def apply(in: Input) = { 155 | val source = in.source 156 | val offset = in.offset 157 | val start = handleWhiteSpace(source, offset) 158 | (r findPrefixMatchOf (source.subSequence(start, source.length))) match { 159 | case Some(matched) => Success(matched, in.drop(start + matched.end - offset)) 160 | case None => 161 | Failure("string matching regex `" + r + 162 | "' expected but `" + 163 | in.first + "' found", in.drop(start - offset)) 164 | } 165 | } 166 | } 167 | 168 | def apply(items: SExp*): SExpList = { 169 | SExpList(items) 170 | } 171 | 172 | def apply(items: Iterable[SExp]): SExpList = { 173 | SExpList(items) 174 | } 175 | 176 | // Helpers for common case of key,val prop-list. 177 | // Omit keys for nil values. 178 | def propList(items: (String, SExp)*): SExpList = { 179 | propList(items) 180 | } 181 | def propList(items: Iterable[(String, SExp)]): SExpList = { 182 | val nonNil = items.filter { 183 | case (s, NilAtom()) => false 184 | case (s, SExpList(items)) if items.isEmpty => false 185 | case _ => true 186 | } 187 | SExpList(nonNil.flatMap(ea => List(key(ea._1), ea._2))) 188 | } 189 | 190 | implicit def strToSExp(str: String): SExp = { 191 | StringAtom(str) 192 | } 193 | 194 | def key(str: String): KeywordAtom = { 195 | KeywordAtom(str) 196 | } 197 | 198 | implicit def intToSExp(value: Int): SExp = { 199 | IntAtom(value) 200 | } 201 | 202 | implicit def boolToSExp(value: Boolean): SExp = { 203 | if (value) { 204 | TruthAtom() 205 | } else { 206 | NilAtom() 207 | } 208 | } 209 | 210 | implicit def symbolToSExp(value: Symbol): SExp = { 211 | if (value == 'nil) { 212 | NilAtom() 213 | } else { 214 | SymbolAtom(value.toString.drop(1)) 215 | } 216 | } 217 | 218 | implicit def nilToSExpList(nil: NilAtom): SExp = { 219 | SExpList(List()) 220 | } 221 | 222 | implicit def toSExp(o: SExpable): SExp = { 223 | o.toSExp 224 | } 225 | 226 | implicit def toSExpable(o: SExp): SExpable = new SExpable { 227 | def toSExp = o 228 | } 229 | 230 | implicit def listToSExpable(o: Iterable[SExpable]): SExpable = new Iterable[SExpable] with SExpable { 231 | override def iterator = o.iterator 232 | override def toSExp = SExp(o.map { _.toSExp }) 233 | } 234 | 235 | } 236 | 237 | abstract trait SExpable { 238 | implicit def toSExp(): SExp 239 | } 240 | 241 | -------------------------------------------------------------------------------- /project/Project.scala: -------------------------------------------------------------------------------- 1 | import sbt._ 2 | import Keys._ 3 | 4 | object CCCPBuild extends Build { 5 | lazy val root = Project(id = "cccp", base = file(".")) aggregate(server, agent, jeditClient) 6 | lazy val server = Project(id = "cccp-server", base = file("server")) 7 | lazy val agent = Project(id = "cccp-agent", base = file("agent")) dependsOn server 8 | lazy val jeditClient = Project(id = "cccp-jedit-client", base = file("clients/jedit")) 9 | 10 | val stage = TaskKey[Unit]("stage", "Copy files into staging directory for a release.") 11 | } 12 | 13 | -------------------------------------------------------------------------------- /server/bin/cccp-server: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | exec java -classpath com.codecommit.cccp.server.Main $* 4 | -------------------------------------------------------------------------------- /server/bin/cccp-server.bat: -------------------------------------------------------------------------------- 1 | java -classpath com.codecommit.cccp.server.Main %* 2 | -------------------------------------------------------------------------------- /server/build.sbt: -------------------------------------------------------------------------------- 1 | import IO._ 2 | 3 | name := "cccp-server" 4 | 5 | libraryDependencies ++= Seq( 6 | "se.scalablesolutions.akka" % "akka-actor" % "1.2", 7 | "com.reportgrid" %% "blueeyes" % "0.4.24", 8 | "org.specs2" %% "specs2" % "1.7-SNAPSHOT" % "test") 9 | 10 | resolvers ++= Seq( 11 | "Sonatype" at "http://nexus.scala-tools.org/content/repositories/public", 12 | "Scala Tools" at "http://scala-tools.org/repo-snapshots/", 13 | "JBoss" at "http://repository.jboss.org/nexus/content/groups/public/", 14 | "Akka" at "http://akka.io/repository/", 15 | "GuiceyFruit" at "http://guiceyfruit.googlecode.com/svn/repo/releases/") 16 | 17 | exportJars := true 18 | 19 | stage <<= (dependencyClasspath in Runtime, exportedProducts in Runtime) map { (depCP, exportedCP) => 20 | // this task "borrowed" from ENSIME (thanks, Aemon!) 21 | val server = Path("server") 22 | val log = LogManager.defaultScreen 23 | delete(file("dist")) 24 | log.info("Copying runtime environment to ./dist....") 25 | createDirectories(List( 26 | file("server/dist"), 27 | file("server/dist/bin"), 28 | file("server/dist/lib"))) 29 | // Copy the runtime jars 30 | val deps = (depCP ++ exportedCP).map(_.data) 31 | copy(deps x flat(server / "dist" / "lib")) 32 | // Grab all jars.. 33 | val cpLibs = (server / "dist" / "lib" ** "*.jar").get.flatMap(_.relativeTo(server / "dist")) 34 | def writeScript(classpath:String, from:String, to:String) { 35 | val tmplF = new File(from) 36 | val tmpl = read(tmplF) 37 | val s = tmpl.replace("", classpath) 38 | val f = new File(to) 39 | write(f, s) 40 | f.setExecutable(true) 41 | } 42 | // Expand the server invocation script templates. 43 | writeScript(cpLibs.mkString(":").replace("\\", "/"), "server/bin/cccp-server", "server/dist/bin/cccp-server") 44 | writeScript("\"" + cpLibs.map{lib => "%~dp0/../" + lib}.mkString(";").replace("/", "\\") + "\"", "server/bin/cccp-server.bat", "server/dist/bin/cccp-server.bat") 45 | // copyFile(root / "README.md", root / "dist" / "README.md") 46 | // copyFile(root / "LICENSE", root / "dist" / "LICENSE") 47 | } 48 | -------------------------------------------------------------------------------- /server/default.conf: -------------------------------------------------------------------------------- 1 | server { 2 | port = 8585 3 | sslPort = 8586 4 | } 5 | -------------------------------------------------------------------------------- /server/lib/fedone-api-0.2.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/djspiewak/cccp/a157ee036e63c6852792ff64b0172a3d8e194da3/server/lib/fedone-api-0.2.jar -------------------------------------------------------------------------------- /server/src/main/scala/com/codecommit/cccp/Op.scala: -------------------------------------------------------------------------------- 1 | package com.codecommit.cccp 2 | 3 | import java.util.UUID 4 | import org.waveprotocol.wave.model.document.operation.BufferedDocOp 5 | import org.waveprotocol.wave.model.document.operation.impl.DocOpBuilder 6 | import org.waveprotocol.wave.model.document.operation.DocOpComponentType 7 | 8 | case class Op(id: String, parent: Int, version: Int, delta: BufferedDocOp) { 9 | 10 | def retain(len: Int) = 11 | copy(delta = builder.retain(len).build) 12 | 13 | def chars(str: String) = 14 | copy(delta = builder.characters(str).build) 15 | 16 | def delete(str: String) = 17 | copy(delta = builder.deleteCharacters(str).build) 18 | 19 | def reparent(parent2: Int) = 20 | copy(parent = parent2, version = parent2 + (version - parent)) 21 | 22 | private def builder = { 23 | import DocOpComponentType._ 24 | 25 | val back = new DocOpBuilder 26 | for (i <- 0 until delta.size) { 27 | delta getType i match { 28 | case CHARACTERS => back.characters(delta getCharactersString i) 29 | case RETAIN => back.retain(delta getRetainItemCount i) 30 | case DELETE_CHARACTERS => back.deleteCharacters(delta getDeleteCharactersString i) 31 | case tpe => throw new IllegalArgumentException("unknown op component: " + tpe) 32 | } 33 | } 34 | back 35 | } 36 | } 37 | 38 | object Op extends ((String, Int, Int, BufferedDocOp) => Op) { 39 | def apply(parent: Int): Op = 40 | Op(UUID.randomUUID().toString, parent, parent + 1, new DocOpBuilder().build) 41 | } 42 | -------------------------------------------------------------------------------- /server/src/main/scala/com/codecommit/cccp/OpFormat.scala: -------------------------------------------------------------------------------- 1 | package com.codecommit.cccp 2 | 3 | import java.io.{Reader, Writer} 4 | import org.waveprotocol.wave.model.document.operation.DocOp 5 | import org.waveprotocol.wave.model.document.operation.DocOpCursor 6 | import org.waveprotocol.wave.model.document.operation.DocOpComponentType 7 | import org.waveprotocol.wave.model.document.operation.impl.DocOpUtil 8 | 9 | object OpFormat { 10 | 11 | def write(ops: Seq[Op], w: Writer) { 12 | import DocOpComponentType._ 13 | 14 | var isFirst = true 15 | for (op <- ops) { 16 | if (!isFirst) { 17 | w.write('\n') 18 | } 19 | isFirst = false 20 | 21 | writeString(op.id, w) 22 | writeInt(op.parent, w) 23 | writeInt(op.version, w) 24 | 25 | for (i <- 0 until op.delta.size) { 26 | op.delta getType i match { 27 | case CHARACTERS => { 28 | w.append("++") 29 | writeString(op.delta.getCharactersString(i), w) 30 | } 31 | 32 | case RETAIN => { 33 | w.append('r') 34 | writeInt(op.delta getRetainItemCount i, w) 35 | } 36 | 37 | case DELETE_CHARACTERS => { 38 | w.append("--") 39 | writeString(op.delta getDeleteCharactersString i, w) 40 | } 41 | 42 | case tpe => throw new IllegalArgumentException("unknown op component: " + tpe) 43 | } 44 | } 45 | } 46 | } 47 | 48 | private def writeInt(i: Int, w: Writer) { 49 | w.append(i.toString) 50 | w.append(';') 51 | } 52 | 53 | private def writeString(str: String, w: Writer) { 54 | w.append(str.length.toString) 55 | w.append(':') 56 | w.append(str) 57 | } 58 | 59 | def read(r: Reader): Seq[Op] = { 60 | var hasNext = true 61 | var back = Vector[Op]() 62 | 63 | while (hasNext) { 64 | hasNext = false 65 | 66 | val id = readString(r) 67 | val parent = readInt(r) 68 | val version = readInt(r) 69 | 70 | val unbuffered = new DocOp { 71 | def apply(c: DocOpCursor) { 72 | var next = r.read() 73 | while (next >= 0) { 74 | val continue = next.toChar match { 75 | case '+' => { 76 | r.read() // TODO 77 | c.characters(readString(r)) 78 | true 79 | } 80 | 81 | case 'r' => { 82 | c.retain(readInt(r)) 83 | true 84 | } 85 | 86 | case '-' => { 87 | r.read() // TODO 88 | c.deleteCharacters(readString(r)) 89 | true 90 | } 91 | 92 | case '\n' => { 93 | hasNext = true 94 | false 95 | } 96 | } 97 | 98 | if (continue) 99 | next = r.read() 100 | else 101 | next = -1 102 | } 103 | } 104 | } 105 | 106 | val delta = DocOpUtil.buffer(unbuffered) 107 | back = back :+ Op(id, parent, version, delta) 108 | } 109 | 110 | back 111 | } 112 | 113 | private def readInt(r: Reader): Int = { 114 | val str = new StringBuilder 115 | 116 | var c = r.read().toChar 117 | while (c - '0' <= '9' - '0') { 118 | str.append(c) 119 | c = r.read().toChar 120 | } 121 | 122 | str.toString.toInt 123 | } 124 | 125 | private def readString(r: Reader): String = { 126 | val length = readInt(r) 127 | val buffer = new Array[Char](length) 128 | r.read(buffer) // TODO 129 | new String(buffer) 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /server/src/main/scala/com/codecommit/cccp/server/CCCPService.scala: -------------------------------------------------------------------------------- 1 | package com.codecommit.cccp 2 | package server 3 | 4 | import akka.actor.Actor 5 | 6 | import blueeyes._ 7 | import blueeyes.core.data._ 8 | import blueeyes.core.http._ 9 | 10 | import java.io.{ByteArrayOutputStream, CharArrayReader, OutputStreamWriter} 11 | 12 | trait CCCPService extends BlueEyesServiceBuilder { 13 | import FilesActor._ 14 | import MimeTypes._ 15 | import OpChunkUtil._ 16 | 17 | lazy val files = Actor.actorOf[FilesActor].start() 18 | 19 | // TODO content type for operation 20 | 21 | val cccpService = service("cccp", "0.1") { 22 | logging { log => context => 23 | request { 24 | path("/'id/") { 25 | produce(text/plain) { 26 | path('version) { 27 | get { request: HttpRequest[ByteChunk] => 28 | implicit val timeout = Actor.Timeout(2 * 60 * 1000) 29 | 30 | log.info("accessing history at a version " + request.parameters('version)) 31 | 32 | val response = files ? RequestHistory(request parameters 'id, request.parameters('version).toInt) 33 | val back = new blueeyes.concurrent.Future[Option[ByteChunk]] 34 | 35 | response.as[Seq[Op]] foreach { ops => 36 | log.info("delivering operations: " + new String(opToChunk(ops).data)) 37 | back deliver Some(opToChunk(ops)) 38 | } 39 | 40 | response onTimeout { _ => 41 | back deliver None 42 | } 43 | 44 | back map { d => HttpResponse(content = d) } 45 | } 46 | } ~ 47 | post { request: HttpRequest[ByteChunk] => 48 | for (content <- request.content) { 49 | log.info("applying operation(s): " + (content.data map { _.toChar } mkString)) 50 | val ops = chunkToOp(content) 51 | ops foreach { op => files ! PerformEdit(request parameters 'id, op) } 52 | } 53 | 54 | blueeyes.concurrent.Future.sync(HttpResponse(content = None)) 55 | } 56 | } 57 | } 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /server/src/main/scala/com/codecommit/cccp/server/FilesActor.scala: -------------------------------------------------------------------------------- 1 | package com.codecommit.cccp 2 | package server 3 | 4 | import akka.actor._ 5 | 6 | class FilesActor extends Actor { 7 | import Actor._ 8 | import FilesActor._ 9 | 10 | @volatile 11 | private var files = Map[String, ActorRef]() 12 | 13 | def receive = { 14 | case PerformEdit(id, op) => fileRef(id) ! op 15 | case RequestHistory(id, from) => fileRef(id) ! OTActor.RequestHistory(self.channel, from) 16 | } 17 | 18 | private def fileRef(id: String) = { 19 | files get id getOrElse { 20 | val back = actorOf[OTActor].start() 21 | files += (id -> back) 22 | back 23 | } 24 | } 25 | } 26 | 27 | object FilesActor { 28 | case class PerformEdit(id: String, op: Op) 29 | case class RequestHistory(id: String, from: Int) 30 | } 31 | -------------------------------------------------------------------------------- /server/src/main/scala/com/codecommit/cccp/server/Main.scala: -------------------------------------------------------------------------------- 1 | package com.codecommit.cccp 2 | package server 3 | 4 | import blueeyes.BlueEyesServer 5 | 6 | object Main extends BlueEyesServer with CCCPService 7 | -------------------------------------------------------------------------------- /server/src/main/scala/com/codecommit/cccp/server/OTActor.scala: -------------------------------------------------------------------------------- 1 | package com.codecommit.cccp 2 | package server 3 | 4 | import akka.actor._ 5 | import scala.collection.SortedMap 6 | 7 | class OTActor extends Actor { 8 | import OTActor._ 9 | 10 | @volatile 11 | private var history: OpHistory = _ 12 | 13 | @volatile 14 | private var listeners = SortedMap[Int, Set[Channel[Seq[Op]]]]() 15 | 16 | def receive = { 17 | case op: Op => { 18 | if (history == null) { 19 | history = new OpHistory(op) 20 | broadcast(op) 21 | } else if (history != null && history.isDefinedAt(op)) { 22 | val (op2, history2) = history(op) 23 | history = history2 24 | broadcast(op2) 25 | } 26 | } 27 | 28 | // TODO error handling on operations 29 | 30 | case RequestHistory(channel, version) => { 31 | if (history == null || version > history.version) { 32 | if (listeners contains version) 33 | listeners = listeners.updated(version, listeners(version) + channel) 34 | else 35 | listeners += (version -> Set(channel)) 36 | } else { 37 | channel ! history.from(version) 38 | } 39 | } 40 | } 41 | 42 | private def broadcast(op: Op) { 43 | val channels = (listeners to op.version values).flatten 44 | listeners = listeners from (op.version + 1) 45 | channels foreach { _ ! Vector(op) } 46 | } 47 | } 48 | 49 | object OTActor { 50 | case class RequestHistory(channel: Channel[Seq[Op]], from: Int) 51 | } 52 | -------------------------------------------------------------------------------- /server/src/main/scala/com/codecommit/cccp/server/OpChunkUtil.scala: -------------------------------------------------------------------------------- 1 | package com.codecommit.cccp 2 | package server 3 | 4 | import blueeyes._ 5 | import blueeyes.core.data._ 6 | import blueeyes.core.http._ 7 | 8 | import java.io.{ByteArrayOutputStream, CharArrayReader, OutputStreamWriter} 9 | 10 | object OpChunkUtil { 11 | def opToChunk(ops: Seq[Op]): ByteChunk = { 12 | val os = new ByteArrayOutputStream 13 | val writer = new OutputStreamWriter(os) 14 | OpFormat.write(ops, writer) 15 | writer.close() 16 | 17 | new MemoryChunk(os.toByteArray, { () => None }) 18 | } 19 | 20 | def chunkToOp(chunk: ByteChunk): Seq[Op] = { 21 | val reader = new CharArrayReader(chunk.data map { _.toChar }) 22 | OpFormat.read(reader) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /server/src/main/scala/com/codecommit/cccp/server/OpHistory.scala: -------------------------------------------------------------------------------- 1 | package com.codecommit.cccp 2 | package server 3 | 4 | import org.waveprotocol.wave.model.document.operation.algorithm.{Composer, Transformer} 5 | import scala.collection.JavaConverters._ 6 | import scala.collection.immutable.SortedMap 7 | 8 | final class OpHistory private (history: SortedMap[Int, Op]) extends PartialFunction[Op, (Op, OpHistory)] { 9 | import Function._ 10 | 11 | def this(base: Op) = this(SortedMap(base.version -> base)) 12 | 13 | def version = history.last._1 14 | 15 | def apply(op: Op) = { 16 | if (!(history contains op.parent)) { 17 | throw new IllegalArgumentException("parent version %d is not in history".format(op.version)) 18 | } else { 19 | val op2 = attemptTransform(op) 20 | val history2 = history + (op2.version -> op2) 21 | (op2, new OpHistory(history2)) 22 | } 23 | } 24 | 25 | def isDefinedAt(op: Op) = { 26 | lazy val canTransform = try { 27 | attemptTransform(op) 28 | true 29 | } catch { 30 | case _ => false 31 | } 32 | 33 | (history contains op.parent) && canTransform 34 | } 35 | 36 | def from(version: Int): Seq[Op] = (history from version values).toSeq 37 | 38 | private def attemptTransform(op: Op): Op = { 39 | val intervening = history from (op.parent + 1) values 40 | 41 | if (intervening.isEmpty) { 42 | op 43 | } else { 44 | val server = Composer.compose(intervening map { _.delta } asJava) 45 | val pair = Transformer.transform(op.delta, server) 46 | op.copy(delta = pair.clientOp).reparent(intervening.last.version) 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /server/src/test/scala/com/codecommit/cccp/OpFormatSpecs.scala: -------------------------------------------------------------------------------- 1 | package com.codecommit.cccp 2 | 3 | import org.specs2.mutable._ 4 | import java.io.ByteArrayOutputStream 5 | import java.io.OutputStreamWriter 6 | import java.io.CharArrayReader 7 | 8 | object OpFormatSpecs extends Specification { 9 | 10 | "operation serialization" should { 11 | "serialize/deserialize a single operation" in { 12 | val op = Op(0).retain(12).chars("test") 13 | val str = writeToString(Vector(op)) 14 | val ops = readFromString(str) 15 | 16 | ops must haveSize(1) 17 | ops.head must beLike { 18 | case Op(id, 0, 1, delta) if id == op.id => { 19 | delta.size mustEqual 2 20 | delta.getRetainItemCount(0) mustEqual 12 21 | delta.getCharactersString(1) mustEqual "test" 22 | } 23 | } 24 | } 25 | 26 | "serialize/deserialize multiple operations" in { 27 | val op1 = Op(0).retain(12).chars("test") 28 | val op2 = Op(1).chars("boo!").retain(37).delete("test?") 29 | 30 | val str = writeToString(Vector(op1, op2)) 31 | val ops = readFromString(str) 32 | 33 | ops must haveSize(2) 34 | 35 | ops(0) must beLike { 36 | case Op(id, 0, 1, delta) if id == op1.id => { 37 | delta.size mustEqual 2 38 | delta.getRetainItemCount(0) mustEqual 12 39 | delta.getCharactersString(1) mustEqual "test" 40 | } 41 | } 42 | 43 | ops(1) must beLike { 44 | case Op(id, 1, 2, delta) if id == op2.id => { 45 | delta.size mustEqual 3 46 | delta.getCharactersString(0) mustEqual "boo!" 47 | delta.getRetainItemCount(1) mustEqual 37 48 | delta.getDeleteCharactersString(2) mustEqual "test?" 49 | } 50 | } 51 | } 52 | } 53 | 54 | def writeToString(ops: Seq[Op]): String = { 55 | val os = new ByteArrayOutputStream 56 | val writer = new OutputStreamWriter(os) 57 | OpFormat.write(ops, writer) 58 | writer.close() 59 | 60 | os.toByteArray map { _.toChar } mkString 61 | } 62 | 63 | def readFromString(str: String): Seq[Op] = { 64 | val reader = new CharArrayReader(str.toArray) 65 | OpFormat.read(reader) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /server/src/test/scala/com/codecommit/cccp/server/OpHistorySpecs.scala: -------------------------------------------------------------------------------- 1 | package com.codecommit.cccp 2 | package server 3 | 4 | import org.specs2.mutable.Specification 5 | 6 | object OpHistorySpecs extends Specification { 7 | 8 | "operation history" should { 9 | "know the latest version" in { 10 | var hist = new OpHistory(Op(42)) 11 | hist.version mustEqual 43 12 | 13 | hist = hist(Op(43).chars("test"))._2 14 | hist.version mustEqual 44 15 | } 16 | 17 | "transform incoming operations against intervening history" in { 18 | var hist = new OpHistory(Op(0)) 19 | 20 | { 21 | val (op, hist2) = hist(Op(1).chars("test")) 22 | hist = hist2 23 | 24 | op.parent mustEqual 1 25 | op.version mustEqual 2 26 | 27 | op.delta.size mustEqual 1 28 | op.delta.getCharactersString(0) mustEqual "test" 29 | } 30 | 31 | { 32 | val (op, hist2) = hist(Op(2).retain(4).chars("ing")) 33 | hist = hist2 34 | 35 | op.parent mustEqual 2 36 | op.version mustEqual 3 37 | 38 | op.delta.size mustEqual 2 39 | op.delta.getRetainItemCount(0) mustEqual 4 40 | op.delta.getCharactersString(1) mustEqual "ing" 41 | } 42 | 43 | { 44 | val (op, hist2) = hist(Op(2).delete("test").chars("stomp")) 45 | hist = hist2 46 | 47 | op.parent mustEqual 3 48 | op.version mustEqual 4 49 | 50 | op.delta.size mustEqual 3 51 | op.delta.getDeleteCharactersString(0) mustEqual "test" 52 | op.delta.getCharactersString(1) mustEqual "stomp" 53 | op.delta.getRetainItemCount(2) mustEqual 3 54 | } 55 | 56 | { 57 | val (op, hist2) = hist(Op(1).chars("we are ")) 58 | hist = hist2 59 | 60 | op.parent mustEqual 4 61 | op.version mustEqual 5 62 | 63 | op.delta.size mustEqual 2 64 | op.delta.getCharactersString(0) mustEqual "we are " 65 | op.delta.getRetainItemCount(1) mustEqual 8 66 | } 67 | } 68 | } 69 | } 70 | --------------------------------------------------------------------------------