diff --git a/CHANGELOG.md b/CHANGELOG.md index 7063435..d97f77d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@
-## Unreleased (2024-06-07) +## Unreleased (2024-06-08)
@@ -20,6 +20,7 @@ ##### Features +- [`b4c12b7`](https://github.com/stdlib-js/stdlib/commit/b4c12b7c4a76cfa71164d1b01fcbfca0426abbb3) - add APIs, commands, and tests for REPL syntax-highlighting [(#2291)](https://github.com/stdlib-js/stdlib/pull/2291) - [`24f4a8f`](https://github.com/stdlib-js/stdlib/commit/24f4a8f24c08dd25686afc4cfb78be2e0045e844) - add syntax highlighting in the REPL - [`0f9acd1`](https://github.com/stdlib-js/stdlib/commit/0f9acd17de012dfe755c98b602d6bb3dbe1e8117) - add `BooleanArray` to namespace - [`3c31c1f`](https://github.com/stdlib-js/stdlib/commit/3c31c1f54ab8e1148fd9104490245c60cc540280) - add REPL pager [(#2162)](https://github.com/stdlib-js/stdlib/pull/2162) @@ -255,6 +256,7 @@ A total of 10 people contributed to this release. Thank you to the following con
+- [`b4c12b7`](https://github.com/stdlib-js/stdlib/commit/b4c12b7c4a76cfa71164d1b01fcbfca0426abbb3) - **feat:** add APIs, commands, and tests for REPL syntax-highlighting [(#2291)](https://github.com/stdlib-js/stdlib/pull/2291) _(by Snehil Shah, Athan Reines)_ - [`f85ed2a`](https://github.com/stdlib-js/stdlib/commit/f85ed2aafc393cfbac360ad14b97af0ee28d450b) - **docs:** update REPL namespace documentation [(#2313)](https://github.com/stdlib-js/stdlib/pull/2313) _(by stdlib-bot, Athan Reines)_ - [`f10aaf2`](https://github.com/stdlib-js/stdlib/commit/f10aaf243f4873488bf04be4bad5c3d256415a41) - **docs:** update REPL namespace documentation [(#2311)](https://github.com/stdlib-js/stdlib/pull/2311) _(by stdlib-bot, Athan Reines)_ - [`34ef42e`](https://github.com/stdlib-js/stdlib/commit/34ef42e798ec33e1f3a62a15a460dd46ae7c693c) - **docs:** update REPL namespace documentation [(#2246)](https://github.com/stdlib-js/stdlib/pull/2246) _(by stdlib-bot, Athan Reines)_ diff --git a/README.md b/README.md index 0583c5a..9a9ccef 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ The function accepts the following `options`: - **outputPrompt**: output prompt. If the output prompt includes the character sequence `%d`, the output prompt includes line numbers. Default: `'Out[%d]: '`. - **welcome**: welcome message. - **padding**: number of empty lines between consecutive commands. Default: `1`. +- **themes**: table containing color themes for syntax highlighting. - **load**: file path specifying a JavaScript file to load and evaluate line-by-line (e.g., a previous REPL history file). - **save**: file path specifying where to save REPL command history. - **log**: file path specifying where to save REPL commands and printed output. @@ -106,6 +107,27 @@ The function supports specifying the following settings: - **autoDeletePairs**: boolean indicating whether to automatically delete adjacent matching brackets, parentheses, and quotes. Default: `true`. - **autoPage**: boolean indicating whether to automatically page return values having a display size exceeding the visible screen. When streams are TTY, the default is `true`; otherwise, the default is `false`. - **completionPreviews**: boolean indicating whether to display completion previews for auto-completion. When streams are TTY, the default is `true`; otherwise, the default is `false`. +- **syntaxHighlighting**: boolean indicating whether to enable syntax highlighting of entered input expressions. When streams are TTY, the default is `true`; otherwise, the default is `false`. +- **theme**: initial color theme for syntax highlighting. Default: `stdlib-ansi-basic`. + +#### REPL.prototype.viewport() + +Returns the REPL viewport. + +```javascript +var debug = require( '@stdlib/streams/node/debug-sink' ); + +// Create a new REPL: +var repl = new REPL({ + 'output': debug() +}); + +// Query the REPL viewport: +var v = repl.viewport(); + +// Close the REPL: +repl.close(); +``` #### REPL.prototype.createContext() @@ -176,7 +198,7 @@ repl.clearHistory(); repl.close(); ``` -#### Repl.prototype.clearUserDocs() +#### REPL.prototype.clearUserDocs() Clears user-defined documentation. @@ -199,6 +221,167 @@ repl.clearUserDocs(); repl.close(); ``` +#### REPL.prototype.themes() + +Returns a list of all available themes for syntax highlighting. + +```javascript +var debug = require( '@stdlib/streams/node/debug-sink' ); + +// Create a new REPL: +var repl = new REPL({ + 'output': debug() +}); + +// ... + +// Fetch all available themes: +var themes = repl.themes(); +// returns [...] + +// ... + +// Close the REPL: +repl.close(); +``` + +#### REPL.prototype.getTheme( name ) + +Returns a theme's color palette for syntax highlighting. + +```javascript +var debug = require( '@stdlib/streams/node/debug-sink' ); + +// Create a new REPL: +var repl = new REPL({ + 'output': debug() +}); + +// ... + +// Add a user-defined theme: +repl.addTheme( 'myTheme', { + 'keyword': 'red' +}); + +// Get a theme's color palette: +var theme = repl.getTheme( 'myTheme' ); +// returns { 'keyword': 'red' } + +// ... + +// Close the REPL: +repl.close(); +``` + +#### REPL.prototype.addTheme( name, theme ) + +Adds a syntax highlighting theme. + +```javascript +var debug = require( '@stdlib/streams/node/debug-sink' ); + +// Create a new REPL: +var repl = new REPL({ + 'output': debug() +}); + +// ... + +// Add a user-defined theme: +repl.addTheme( 'myTheme', { + 'keyword': 'red', + 'variable': 'green' + + // ... +}); + +// ... + +// Close the REPL: +repl.close(); +``` + +The syntax-highlighter supports the following tokens and associated theme fields: + +- **keyword**: keywords (e.g., `var`, `function`, `let`, `const`, `in`, and `class`). +- **control**: control flow keywords (e.g., `if`, `else`, `try`, `catch`, and `return`). +- **specialIdentifier**: special identifiers (e.g., `this` and `super`). +- **string**: string and template literals. +- **number**: numeric literals. +- **literal**: reserved literals (e.g., `true`, `false`, `null`, and `undefined`). +- **regexp**: regular expressions. +- **command**: built-in REPL commands. +- **function**: function identifiers. +- **object**: object identifiers. +- **variable**: literal identifiers. +- **name**: variable names. +- **comment**: line comments. +- **punctuation**: punctuation symbols (e.g., `;`, `[`, `{`, `,`, and `?`). +- **operator**: operator symbols (e.g., `+`, `-`, `*`, `=`, `++`, `>=`, and `&&`). + +#### REPL.prototype.deleteTheme( name ) + +Deletes a specified theme from the syntax-highlighter. + +```javascript +var debug = require( '@stdlib/streams/node/debug-sink' ); + +// Create a new REPL: +var repl = new REPL({ + 'output': debug() +}); + +// ... + +// Add a user-defined theme: +repl.addTheme( 'myTheme', { + 'keyword': 'red', + 'variable': 'green' + + // ... +}); + +// Delete the added theme: +repl.deleteTheme( 'myTheme' ); + +// ... + +// Close the REPL: +repl.close(); +``` + +#### REPL.prototype.renameTheme( oldName, newName ) + +Renames a specified theme in the syntax-highlighter. + +```javascript +var debug = require( '@stdlib/streams/node/debug-sink' ); + +// Create a new REPL: +var repl = new REPL({ + 'output': debug() +}); + +// ... + +// Add a user-defined theme: +repl.addTheme( 'myTheme', { + 'keyword': 'red', + 'variable': 'green' + + // ... +}); + +// Rename the added theme: +repl.renameTheme( 'myTheme', 'yourTheme' ); + +// ... + +// Close the REPL: +repl.close(); +``` + #### REPL.prototype.load( fpath, clbk ) Loads and evaluates a JavaScript file line-by-line. @@ -409,6 +592,19 @@ repl.close(); REPL instances support the following commands... +#### addTheme( name, theme ) + +Adds a syntax highlighting color theme. + +```text +// Add color theme: +In [1]: addTheme( 'myTheme', { 'keyword': 'red' } ) + +// Check updated list of themes: +In [2]: themes() +Out[2]: [ 'stdlib-ansi-basic', 'myTheme' ] +``` + #### alias2pkg( arg ) Returns the package name associated with a provided alias or class instance. @@ -620,6 +816,26 @@ In [1]: currentWorkspace Out[1]: 'base' ``` +#### deleteTheme( name ) + +Deletes a syntax highlighting color theme. + +```text +// Add a color theme: +In [1]: addTheme( 'myTheme', { 'keyword': 'red' } ) + +// Check list of themes: +In [2]: themes() +Out[2]: [ 'stdlib-ansi-basic', 'myTheme' ] + +// Delete the added theme: +In [3]: deleteTheme( 'myTheme' ) + +// Check updated list of themes: +In [4]: themes() +Out[4]: [ 'stdlib-ansi-basic' ] +``` + #### deeprerequire( id ) Re-imports a module, JSON, or local file and all of its associated module dependencies. @@ -718,6 +934,21 @@ In [1]: example( base.sin ) **Note**: only direct instances of documented built-in constructors are supported. +#### getTheme( \[name] ) + +Returns a syntax highlighting color theme. + +```text +// Add a color theme: +In [1]: addTheme( 'myTheme', { 'keyword': 'red' } ) + +// Get the color theme: +In [2]: getTheme( 'myTheme' ) +Out[2]: { 'keyword': 'red' } +``` + +**Note**: if no theme name is provided, the current theme is returned. + #### help( \[arg] ) Prints help text. @@ -899,6 +1130,26 @@ Exits the REPL. In [1]: quit() ``` +#### renameTheme( oldName, newName ) + +Renames a syntax highlighting color theme. + +```text +// Add a color theme: +In [1]: addTheme( 'myTheme', { 'keyword': 'red' } ) + +// Check list of themes: +In [2]: themes() +Out[2]: [ 'stdlib-ansi-basic', 'myTheme' ] + +// Rename the added theme: +In [3]: getTheme( 'myTheme', 'yourTheme' ) + +// Check updated list of themes: +In [4]: themes() +Out[4]: [ 'stdlib-ansi-basic', 'yourTheme' ] +``` + #### renameWorkspace( oldName, newName ) Renames a workspace. @@ -1082,6 +1333,19 @@ To update a specific setting, provide a `value` argument. In [1]: settings( 'autoClosePairs', false ) ``` +#### themes() + +Returns a list of all available syntax highlighting color themes. + +```text +// Add a color theme: +In [1]: addTheme( 'myTheme', { 'keyword': 'red' } ) + +// Check list of themes: +In [2]: themes() +Out[2]: [ 'stdlib-ansi-basic', 'myTheme' ] +``` + #### tutorial( \[name, \[options]] ) Starts a tutorial. diff --git a/lib/commands.js b/lib/commands.js index f5ceec9..6d843e9 100644 --- a/lib/commands.js +++ b/lib/commands.js @@ -20,6 +20,7 @@ // MODULES // +var onAddTheme = require( './commands/add_theme.js' ); var onAlias2Pkg = require( './commands/alias2pkg.js' ); var onAlias2Related = require( './commands/alias2related.js' ); var onAns = require( './commands/ans.js' ); @@ -35,12 +36,14 @@ var onContributors = require( './commands/contributor.js' ); var onCopyright = require( './commands/copyright.js' ); var onCredits = require( './commands/credits.js' ); var onCurrentWorkspace = require( './commands/current_workspace.js' ); +var onDeleteTheme = require( './commands/delete_theme.js' ); var onDeleteWorkspace = require( './commands/delete_workspace.js' ); var onDeeprerequire = require( './commands/deeprerequire.js' ); var onDonate = require( './commands/donate.js' ); var onDone = require( './commands/__done__.js' ); var onEvalin = require( './commands/evalin.js' ); var onExample = require( './commands/example.js' ); +var onGetTheme = require( './commands/get_theme.js' ); var onHelp = require( './commands/help.js' ); var onInfo = require( './commands/info.js' ); var isWorkspace = require( './commands/is_workspace.js' ); @@ -52,11 +55,13 @@ var onPager = require( './commands/pager.js' ); var onPresentationStart = require( './commands/presentation_start.js' ); var onPresentationStop = require( './commands/presentation_stop.js' ); var onQuit = require( './commands/quit.js' ); +var onRenameTheme = require( './commands/rename_theme.js' ); var onRenameWorkspace = require( './commands/rename_workspace.js' ); var onRerequire = require( './commands/rerequire.js' ); var onRerun = require( './commands/rerun.js' ); var onReset = require( './commands/reset.js' ); var onSettings = require( './commands/settings.js' ); +var onThemes = require( './commands/themes.js' ); var onTutorial = require( './commands/tutorial.js' ); var onUserDoc = require( './commands/user_doc.js' ); var onVars = require( './commands/vars.js' ); @@ -90,6 +95,7 @@ function commands( repl ) { // Define a list of REPL-specific commands (NOTE: keep in alphabetical order): cmds = []; cmds.push( [ '__done__', onDone( repl ), false ] ); + cmds.push( [ 'addTheme', onAddTheme( repl ), false ] ); cmds.push( [ 'alias2pkg', onAlias2Pkg( repl ), false ] ); cmds.push( [ 'alias2related', onAlias2Related( repl, cmds ), false ] ); cmds.push( [ 'ans', onAns( repl ), true ] ); @@ -105,11 +111,13 @@ function commands( repl ) { cmds.push( [ 'copyright', onCopyright( repl ), false ] ); cmds.push( [ 'credits', onCredits( repl ), false ] ); cmds.push( [ 'currentWorkspace', onCurrentWorkspace( repl ), true ] ); + cmds.push( [ 'deleteTheme', onDeleteTheme( repl ), false ] ); cmds.push( [ 'deleteWorkspace', onDeleteWorkspace( repl ), false ] ); cmds.push( [ 'deeprerequire', onDeeprerequire( repl ), false ] ); cmds.push( [ 'donate', onDonate( repl ), false ] ); cmds.push( [ 'evalin', onEvalin( repl, cmds ), false ] ); cmds.push( [ 'example', onExample( repl, cmds ), false ] ); + cmds.push( [ 'getTheme', onGetTheme( repl ), false ] ); cmds.push( [ 'help', onHelp( repl, cmds ), false ] ); cmds.push( [ 'info', onInfo( repl, cmds ), false ] ); cmds.push( [ 'isKeyword', isKeyword( repl ), false ] ); @@ -121,11 +129,13 @@ function commands( repl ) { cmds.push( [ 'presentationStart', onPresentationStart( repl ), false ] ); cmds.push( [ 'presentationStop', onPresentationStop( repl ), false ] ); cmds.push( [ 'quit', onQuit( repl ), false ] ); + cmds.push( [ 'renameTheme', onRenameTheme( repl ), false ] ); cmds.push( [ 'renameWorkspace', onRenameWorkspace( repl ), false ] ); cmds.push( [ 'rerequire', onRerequire( repl ), false ] ); cmds.push( [ 'rerun', onRerun( repl ), false ] ); cmds.push( [ 'reset', onReset( repl ), false ] ); cmds.push( [ 'settings', onSettings( repl ), false ] ); + cmds.push( [ 'themes', onThemes( repl ), false ] ); cmds.push( [ 'tutorial', onTutorial( repl ), false ] ); cmds.push( [ 'userDoc', onUserDoc( repl ), false ] ); cmds.push( [ 'vars', onVars( repl ), false ] ); diff --git a/lib/commands/add_theme.js b/lib/commands/add_theme.js new file mode 100644 index 0000000..b978ab7 --- /dev/null +++ b/lib/commands/add_theme.js @@ -0,0 +1,63 @@ +/** +* @license Apache-2.0 +* +* Copyright (c) 2024 The Stdlib Authors. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +/* eslint-disable no-underscore-dangle */ + +'use strict'; + +// MODULES // + +var format = require( '@stdlib/string/format' ); +var log = require( './../log.js' ); + + +// MAIN // + +/** +* Returns a callback to be invoked upon calling the `addTheme` command. +* +* @private +* @param {REPL} repl - REPL instance +* @returns {Function} callback +*/ +function command( repl ) { + return onCommand; + + /** + * Adds a syntax highlighting color theme. + * + * @private + * @param {string} name - theme name + * @param {Object} theme - theme object + * @returns {void} + */ + function onCommand( name, theme ) { + try { + repl.addTheme( name, theme ); + } catch ( err ) { + repl._ostream.write( format( 'Error: %s\n', err.message ) ); + return; + } + log( repl, format( '\nSuccessfully added theme `%s`.', name ) ); + } +} + + +// EXPORTS // + +module.exports = command; diff --git a/lib/commands/delete_theme.js b/lib/commands/delete_theme.js new file mode 100644 index 0000000..7322732 --- /dev/null +++ b/lib/commands/delete_theme.js @@ -0,0 +1,62 @@ +/** +* @license Apache-2.0 +* +* Copyright (c) 2024 The Stdlib Authors. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +/* eslint-disable no-underscore-dangle */ + +'use strict'; + +// MODULES // + +var format = require( '@stdlib/string/format' ); +var log = require( './../log.js' ); + + +// MAIN // + +/** +* Returns a callback to be invoked upon calling the `deleteTheme` command. +* +* @private +* @param {REPL} repl - REPL instance +* @returns {Function} callback +*/ +function command( repl ) { + return onCommand; + + /** + * Deletes a syntax highlighting color theme. + * + * @private + * @param {string} name - theme name + * @returns {void} + */ + function onCommand( name ) { + try { + repl.deleteTheme( name ); + } catch ( err ) { + repl._ostream.write( format( 'Error: %s\n', err.message ) ); + return; + } + log( repl, format( '\nSuccessfully deleted theme `%s`.', name ) ); + } +} + + +// EXPORTS // + +module.exports = command; diff --git a/lib/commands/get_theme.js b/lib/commands/get_theme.js new file mode 100644 index 0000000..05ce9a7 --- /dev/null +++ b/lib/commands/get_theme.js @@ -0,0 +1,70 @@ +/** +* @license Apache-2.0 +* +* Copyright (c) 2024 The Stdlib Authors. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +/* eslint-disable no-underscore-dangle */ + +'use strict'; + +// MODULES // + +var format = require( '@stdlib/string/format' ); + + +// MAIN // + +/** +* Returns a callback to be invoked upon calling the `getTheme` command. +* +* @private +* @param {REPL} repl - REPL instance +* @returns {Function} callback +*/ +function command( repl ) { + return onCommand; + + /** + * Returns a syntax highlighting color theme. + * + * @private + * @param {string} [name] - theme name + * @returns {(Object|void)} theme object + */ + function onCommand() { + var theme; + var name; + + // If no theme name given, get the current theme... + if ( arguments.length === 0 ) { + name = repl.settings( 'theme' ); + } else { + name = arguments[ 0 ]; + } + try { + theme = repl.getTheme( name ); + } catch ( err ) { + repl._ostream.write( format( 'Error: %s\n', err.message ) ); + return; + } + return theme; + } +} + + +// EXPORTS // + +module.exports = command; diff --git a/lib/commands/rename_theme.js b/lib/commands/rename_theme.js new file mode 100644 index 0000000..81b6182 --- /dev/null +++ b/lib/commands/rename_theme.js @@ -0,0 +1,63 @@ +/** +* @license Apache-2.0 +* +* Copyright (c) 2024 The Stdlib Authors. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +/* eslint-disable no-underscore-dangle */ + +'use strict'; + +// MODULES // + +var format = require( '@stdlib/string/format' ); +var log = require( './../log.js' ); + + +// MAIN // + +/** +* Returns a callback to be invoked upon calling the `renameTheme` command. +* +* @private +* @param {REPL} repl - REPL instance +* @returns {Function} callback +*/ +function command( repl ) { + return onCommand; + + /** + * Renames a syntax highlighting color theme. + * + * @private + * @param {string} oldName - old theme name + * @param {string} newName - new theme name + * @returns {void} + */ + function onCommand( oldName, newName ) { + try { + repl.renameTheme( oldName, newName ); + } catch ( err ) { + repl._ostream.write( format( 'Error: %s\n', err.message ) ); + return; + } + log( repl, format( '\nSuccessfully renamed theme from `%s` to `%s`.', oldName, newName ) ); + } +} + + +// EXPORTS // + +module.exports = command; diff --git a/lib/commands/themes.js b/lib/commands/themes.js new file mode 100644 index 0000000..d3e1177 --- /dev/null +++ b/lib/commands/themes.js @@ -0,0 +1,47 @@ +/** +* @license Apache-2.0 +* +* Copyright (c) 2024 The Stdlib Authors. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +'use strict'; + +// MAIN // + +/** +* Returns a callback to be invoked upon calling the `themes` command. +* +* @private +* @param {REPL} repl - REPL instance +* @returns {Function} callback +*/ +function command( repl ) { + return onCommand; + + /** + * Returns a list of available syntax highlighting color themes. + * + * @private + * @returns {Array} list of available themes + */ + function onCommand() { + return repl.themes(); + } +} + + +// EXPORTS // + +module.exports = command; diff --git a/lib/defaults.js b/lib/defaults.js index 52ea52e..8f07cbe 100644 --- a/lib/defaults.js +++ b/lib/defaults.js @@ -90,7 +90,13 @@ function defaults() { 'autoPage': void 0, // Flag indicating whether to enable the display of completion previews for auto-completion (note: default depends on whether TTY): - 'completionPreviews': void 0 + 'completionPreviews': void 0, + + // Flag indicating whether to enable syntax highlighting (note: default depends on whether TTY): + 'syntaxHighlighting': void 0, + + // Theme for syntax highlighting: + 'theme': 'stdlib-ansi-basic' } }; } diff --git a/lib/help_text.js b/lib/help_text.js index 2daf600..1073724 100644 --- a/lib/help_text.js +++ b/lib/help_text.js @@ -53,6 +53,12 @@ var MSG = [ ' renameWorkspace() Rename a specified workspace.', ' currentWorkspace Return the name of the current workspace.', '', + ' themes() List available color themes.', + ' addTheme(name,value) Add a new color theme.', + ' deleteTheme(name) Delete a specified color theme.', + ' renameTheme(old,new) Rename a specified color theme.', + ' getTheme([name]) Return the current (or a specified) theme\'s color palette.', + '', ' assignin() Assign a value to a workspace variable.', ' assignfrom() Read in a value from another workspace.', '', diff --git a/lib/main.js b/lib/main.js index d812cf4..d5fc46c 100644 --- a/lib/main.js +++ b/lib/main.js @@ -30,9 +30,11 @@ var logger = require( 'debug' ); var inherit = require( '@stdlib/utils/inherit' ); var isString = require( '@stdlib/assert/is-string' ).isPrimitive; var isNumber = require( '@stdlib/assert/is-number' ).isPrimitive; +var isPlainObject = require( '@stdlib/assert/is-plain-object' ); var isFunction = require( '@stdlib/assert/is-function' ); var isConfigurableProperty = require( '@stdlib/assert/is-configurable-property' ); var hasOwnProp = require( '@stdlib/assert/has-own-property' ); +var objectKeys = require( '@stdlib/utils/keys' ); var setNonEnumerable = require( '@stdlib/utils/define-nonenumerable-property' ); var setNonEnumerableReadOnly = require( '@stdlib/utils/define-nonenumerable-read-only-property' ); var setReadOnly = require( '@stdlib/utils/define-read-only-property' ); @@ -90,6 +92,7 @@ var debug = logger( 'repl' ); * @param {boolean} [options.isTTY] - boolean indicating whether the input and output streams should be treated like a TTY (terminal) and whether the REPL should use ANSI/VT100 escape codes when writing to the output stream * @param {string} [options.welcome] - welcome message * @param {NonNegativeInteger} [options.padding=1] - number of empty lines between successive commands +* @param {Object} [options.themes] - table containing color themes for syntax highlighting * @param {string} [options.load] - file path specifying a JavaScript file to load and evaluate line-by-line (e.g., a previous REPL history file) * @param {string} [options.save] - file path specifying where to save REPL command history * @param {string} [options.log] - file path specifying where to save REPL commands and printed output @@ -99,6 +102,8 @@ var debug = logger( 'repl' ); * @param {boolean} [options.settings.autoDeletePairs=true] - boolean indicating whether to automatically delete adjacent matching brackets, parentheses, and quotes * @param {boolean} [options.settings.autoPage] - boolean indicating whether to automatically page return values requiring a display size exceeding the visible screen * @param {boolean} [options.settings.completionPreviews] - boolean indicating whether to enable completion previews for auto-completion +* @param {boolean} [options.settings.syntaxHighlighting] - boolean indicating whether to enable syntax highlighting +* @param {string} [options.settings.theme] - initial color theme for syntax highlighting * @throws {Error} must provide valid options * @returns {REPL} REPL instance * @@ -122,9 +127,11 @@ var debug = logger( 'repl' ); */ function REPL( options ) { var ostream; + var themes; var opts; var self; var err; + var i; if ( !( this instanceof REPL ) ) { if ( arguments.length ) { @@ -144,6 +151,7 @@ function REPL( options ) { opts.isTTY = ( opts.isTTY === void 0 ) ? opts.output.isTTY : opts.isTTY; opts.settings.autoPage = ( opts.settings.autoPage === void 0 ) ? opts.isTTY : opts.settings.autoPage; // eslint-disable-line max-len opts.settings.completionPreviews = ( opts.settings.completionPreviews === void 0 ) ? opts.isTTY : opts.settings.completionPreviews; // eslint-disable-line max-len + opts.settings.syntaxHighlighting = ( opts.settings.syntaxHighlighting === void 0 ) ? opts.isTTY : opts.settings.syntaxHighlighting; // eslint-disable-line max-len debug( 'Options: %s', JSON.stringify({ 'input': '', @@ -272,7 +280,7 @@ function REPL( options ) { setNonEnumerableReadOnly( this, '_previewCompleter', new PreviewCompleter( this._rli, this._completer, this._ostream, this._settings.completionPreviews ) ); // Initialize a syntax-highlighter: - setNonEnumerableReadOnly( this, '_syntaxHighlighter', new SyntaxHighlighter( this, this._ostream ) ); + setNonEnumerableReadOnly( this, '_syntaxHighlighter', new SyntaxHighlighter( this, this._ostream, this._settings.syntaxHighlighting ) ); // Cache a reference to the private readline interface `ttyWrite` to allow calling the method when wanting default behavior: setNonEnumerableReadOnly( this, '_ttyWrite', this._rli._ttyWrite ); @@ -302,6 +310,16 @@ function REPL( options ) { // TODO: check whether to synchronously initialize a REPL log file + // Add any provided user-defined themes... + if ( opts.themes ) { + themes = objectKeys( opts.themes ); + for ( i = 0; i < themes.length; i++ ) { + this.addTheme( themes[ i ], opts.themes[ themes[ i ] ] ); + } + } + // Set the syntax highlighting theme... + this.settings( 'theme', opts.settings.theme ); + // Check whether to load and execute a JavaScript file (e.g., prior REPL history) upon startup... if ( opts.load ) { this.load( opts.load ); @@ -348,9 +366,7 @@ function REPL( options ) { if ( autoClosed ) { self._previewCompleter.clear(); } - if ( self._isTTY ) { - self._syntaxHighlighter.onKeypress(); - } + self._syntaxHighlighter.onKeypress(); self._previewCompleter.onKeypress( data, key ); } @@ -782,6 +798,217 @@ setNonEnumerableReadOnly( REPL.prototype, 'clearUserDocs', function clearUserDoc return this; }); +/** +* Returns a list of all available themes for syntax highlighting. +* +* @name themes +* @memberof REPL.prototype +* @type {Function} +* @returns {Array} list of all theme names +* +* @example +* var debug = require( '@stdlib/streams/node/debug-sink' ); +* +* // Create a new REPL: +* var repl = new REPL({ +* 'output': debug() +* }); +* +* // ... +* +* // Fetch all available themes: +* var theme = repl.themes(); +* +* // ... +* +* // Close the REPL: +* repl.close(); +*/ +setNonEnumerableReadOnly( REPL.prototype, 'themes', function themes() { + return this._syntaxHighlighter.getThemes(); +}); + +/** +* Returns a theme's color palette for syntax highlighting. +* +* @name getTheme +* @memberof REPL.prototype +* @type {Function} +* @param {string} theme - theme name +* @throws {TypeError} must provide a string +* @throws {Error} must provide an existing theme name +* @returns {Object} theme object +* +* @example +* var debug = require( '@stdlib/streams/node/debug-sink' ); +* +* // Create a new REPL: +* var repl = new REPL({ +* 'output': debug() +* }); +* +* // ... +* +* // Add a user-defined theme: +* repl.addTheme( 'myTheme', { +* 'keyword': 'red' +* }); +* +* // Get a theme's color palette: +* var o = repl.getTheme( 'myTheme' ); +* +* // ... +* +* // Close the REPL: +* repl.close(); +*/ +setNonEnumerableReadOnly( REPL.prototype, 'getTheme', function getTheme( theme ) { + if ( !isString( theme ) ) { + throw new TypeError( format( 'invalid argument. First argument must be a string. Value: `%s`.', theme ) ); + } + return this._syntaxHighlighter.getThemeConfig( theme ); +}); + +/** +* Adds a syntax highlighting theme. +* +* @name addTheme +* @memberof REPL.prototype +* @type {Function} +* @param {string} name - theme name +* @param {Object} theme - theme object +* @throws {TypeError} first argument must be a string +* @throws {TypeError} second argument must be an object +* @returns {void} +* +* @example +* var debug = require( '@stdlib/streams/node/debug-sink' ); +* +* // Create a new REPL: +* var repl = new REPL({ +* 'output': debug() +* }); +* +* // ... +* +* // Add a user-defined theme: +* repl.addTheme( 'myTheme', { +* 'keyword': 'red', +* 'variable': 'green' +* +* // ... +* }); +* +* // ... +* +* // Close the REPL: +* repl.close(); +*/ +setNonEnumerableReadOnly( REPL.prototype, 'addTheme', function addTheme( name, theme ) { + if ( !isString( name ) ) { + throw new TypeError( format( 'invalid argument. First argument must be a string. Value: `%s`.', name ) ); + } + if ( !isPlainObject( theme ) ) { + throw new TypeError( format( 'invalid argument. Second argument must be an object. Value: `%s`.', theme ) ); + } + this._syntaxHighlighter.addTheme( name, theme ); +}); + +/** +* Renames a specified syntax highlighting theme. +* +* @name renameTheme +* @memberof REPL.prototype +* @type {Function} +* @param {string} oldName - old theme name +* @param {string} newName - new theme name +* @throws {TypeError} first argument must be a string +* @throws {TypeError} second argument must be a string +* @throws {Error} first argument must be an existing theme name +* @throws {Error} first argument must not be the default theme name +* @returns {void} +* +* @example +* var debug = require( '@stdlib/streams/node/debug-sink' ); +* +* // Create a new REPL: +* var repl = new REPL({ +* 'output': debug() +* }); +* +* // ... +* +* // Add a user-defined theme: +* repl.addTheme( 'myTheme', { +* 'keyword': 'red', +* 'variable': 'green' +* +* // ... +* }); + +* // Rename the added theme: +* repl.renameTheme( 'myTheme', 'yourTheme' ); +* +* // ... +* +* // Close the REPL: +* repl.close(); +*/ +setNonEnumerableReadOnly( REPL.prototype, 'renameTheme', function renameTheme( oldName, newName ) { + if ( !isString( oldName ) ) { + throw new TypeError( format( 'invalid argument. First argument must be a string. Value: `%s`.', oldName ) ); + } + if ( !isString( newName ) ) { + throw new TypeError( format( 'invalid argument. Second argument must be a string. Value: `%s`.', newName ) ); + } + this._syntaxHighlighter.renameTheme( oldName, newName ); +}); + +/** +* Deletes a specified syntax highlighting theme. +* +* @name deleteTheme +* @memberof REPL.prototype +* @type {Function} +* @param {string} name - theme name +* @throws {TypeError} must provide a string +* @throws {Error} must provide an existing theme name +* @throws {Error} must not provide the default theme +* @returns {void} +* +* @example +* var debug = require( '@stdlib/streams/node/debug-sink' ); +* +* // Create a new REPL: +* var repl = new REPL({ +* 'output': debug() +* }); +* +* // ... +* +* // Add a user-defined theme: +* repl.addTheme( 'myTheme', { +* 'keyword': 'red', +* 'variable': 'green' +* +* // ... +* }); +* +* // Delete the added theme: +* repl.deleteTheme( 'myTheme' ); +* +* // ... +* +* // Close the REPL: +* repl.close(); +*/ +setNonEnumerableReadOnly( REPL.prototype, 'deleteTheme', function deleteTheme( name ) { + if ( !isString( name ) ) { + throw new TypeError( format( 'invalid argument. First argument must be a string. Value: `%s`.', name ) ); + } + this._syntaxHighlighter.deleteTheme( name ); +}); + /** * Loads and evaluates a JavaScript file line-by-line. * @@ -1212,6 +1439,14 @@ setNonEnumerableReadOnly( REPL.prototype, 'settings', function settings() { } else { this._ostream.disablePaging(); } + } else if ( name === 'syntaxHighlighting' ) { + if ( value ) { + this._syntaxHighlighter.enable(); + } else { + this._syntaxHighlighter.disable(); + } + } else if ( name === 'theme' ) { + this._syntaxHighlighter.setTheme( value ); } return this; diff --git a/lib/resolve_local_scopes.js b/lib/resolve_local_scopes.js index 7ec3cda..1a0be6b 100644 --- a/lib/resolve_local_scopes.js +++ b/lib/resolve_local_scopes.js @@ -21,7 +21,6 @@ // MODULES // var walk = require( 'acorn-walk' ).ancestor; -var format = require( '@stdlib/string/format' ); var appendUnique = require( './append_unique.js' ); var isScope = require( './is_scope.js' ); var isBlockScope = require( './is_block_scope.js' ); @@ -129,7 +128,7 @@ function resolveScopes( ast ) { break; default: - throw new Error( format( 'internal error. Unrecognized pattern type: `%s`.', node.type ) ); + break; } } @@ -223,7 +222,7 @@ function resolveScopes( ast ) { * @param {Array} parents - array of parent AST nodes */ function TryStatement( node ) { - if ( node.handler ) { + if ( node.handler && node.handler.param ) { node.handler.locals = node.handler.locals || []; appendUnique( node.handler.locals, node.handler.param.name ); appendUnique( declarations, node.handler.param ); diff --git a/lib/settings.js b/lib/settings.js index b60354e..abfb131 100644 --- a/lib/settings.js +++ b/lib/settings.js @@ -43,6 +43,14 @@ var SETTINGS = { 'completionPreviews': { 'desc': 'Enable the display of completion previews for auto-completion.', 'type': 'boolean' + }, + 'syntaxHighlighting': { + 'desc': 'Enable syntax highlighting.', + 'type': 'boolean' + }, + 'theme': { + 'desc': 'Set the syntax highlighting theme.', + 'type': 'string' } }; diff --git a/lib/syntax_highlighter.js b/lib/syntax_highlighter.js index 0fb92b3..901e15d 100644 --- a/lib/syntax_highlighter.js +++ b/lib/syntax_highlighter.js @@ -24,7 +24,11 @@ var readline = require( 'readline' ); var logger = require( 'debug' ); +var format = require( '@stdlib/string/format' ); var setNonEnumerableReadOnly = require( '@stdlib/utils/define-nonenumerable-read-only-property' ); +var objectKeys = require( '@stdlib/utils/keys' ); +var omit = require( '@stdlib/utils/omit' ); +var hasOwnProp = require( '@stdlib/assert/has-own-property' ); var tokenizer = require( './tokenizer.js' ); var THEMES = require( './themes.js' ); var ANSI = require( './ansi_colors.js' ); @@ -33,6 +37,7 @@ var ANSI = require( './ansi_colors.js' ); // VARIABLES // var debug = logger( 'repl:syntax-highlighter' ); +var DEFAULT_THEME = 'stdlib-ansi-basic'; // FUNCTIONS // @@ -59,14 +64,18 @@ function tokenComparator( a, b ) { * @constructor * @param {REPL} repl - REPL instance * @param {WritableStream} ostream - writable stream +* @param {boolean} enabled - boolean indicating whether the syntax-highlighter should be initially enabled * @returns {SyntaxHighlighter} syntax-highlighter instance */ -function SyntaxHighlighter( repl, ostream ) { +function SyntaxHighlighter( repl, ostream, enabled ) { if ( !( this instanceof SyntaxHighlighter ) ) { - return new SyntaxHighlighter( repl, ostream ); + return new SyntaxHighlighter( repl, ostream, enabled ); } debug( 'Creating a new syntax-highlighter' ); + // Initialize a flag indicating whether the preview completer is enabled: + this._enabled = enabled; + // Cache a reference to the provided REPL instance: this._repl = repl; @@ -82,6 +91,12 @@ function SyntaxHighlighter( repl, ostream ) { // Initialize a buffer to cache the highlighted line: this._highlightedLine = ''; + // Initialize an object storing all available themes: + this._themes = THEMES; + + // Initialize a variable storing the current theme: + this._theme = DEFAULT_THEME; + return this; } @@ -100,7 +115,7 @@ setNonEnumerableReadOnly( SyntaxHighlighter.prototype, '_highlightLine', functio var highlightedLine = ''; var resetCode = ANSI[ 'reset' ]; var colorCode; - var colors = THEMES[ 'default' ]; + var colors = this._themes[ this._theme ]; var offset = 0; var token; var i; @@ -125,6 +140,138 @@ setNonEnumerableReadOnly( SyntaxHighlighter.prototype, '_highlightLine', functio return highlightedLine; }); +/** +* Returns a list of available theme names. +* +* @name getThemes +* @memberof SyntaxHighlighter.prototype +* @type {Function} +* @returns {Array} array of all theme names +*/ +setNonEnumerableReadOnly( SyntaxHighlighter.prototype, 'getThemes', function getThemes() { + return objectKeys( this._themes ); +}); + +/** +* Sets the current color theme. +* +* @name setTheme +* @memberof SyntaxHighlighter.prototype +* @type {Function} +* @param {string} theme - theme name +* @throws {Error} argument must be an existing theme +*/ +setNonEnumerableReadOnly( SyntaxHighlighter.prototype, 'setTheme', function setTheme( theme ) { + if ( !hasOwnProp( this._themes, theme ) ) { + throw new Error( format( 'invalid argument. First argument must be an existing theme name. Value: `%s`.', theme ) ); + } + this._highlightedLine = ''; + this._theme = theme; +}); + +/** +* Returns a theme's color palette. +* +* @name getThemeConfig +* @memberof SyntaxHighlighter.prototype +* @type {Function} +* @param {string} theme - theme name +* @throws {Error} must provide an existing theme name +* @returns {Object} theme object +*/ +setNonEnumerableReadOnly( SyntaxHighlighter.prototype, 'getThemeConfig', function getThemeConfig( theme ) { + if ( !hasOwnProp( this._themes, theme ) ) { + throw new Error( format( 'invalid argument. First argument must be an existing theme name. Value: `%s`.', theme ) ); + } + return this._themes[ theme ]; +}); + +/** +* Adds a new color theme. +* +* @name addTheme +* @memberof SyntaxHighlighter.prototype +* @type {Function} +* @param {Object} theme - theme object +*/ +setNonEnumerableReadOnly( SyntaxHighlighter.prototype, 'addTheme', function addTheme( name, theme ) { + this._themes[ name ] = theme; +}); + +/** +* Renames a color theme. +* +* @name renameTheme +* @memberof SyntaxHighlighter.prototype +* @type {Function} +* @param {string} oldName - old theme name +* @param {string} newName - new theme name +* @throws {Error} first argument must be an existing theme name +*/ +setNonEnumerableReadOnly( SyntaxHighlighter.prototype, 'renameTheme', function renameTheme( oldName, newName ) { + if ( oldName === DEFAULT_THEME ) { + throw new Error( format( 'invalid argument. First argument must be not be the default theme name. Value: `%s`.', oldName ) ); + } + if ( !hasOwnProp( this._themes, oldName ) ) { + throw new Error( format( 'invalid argument. First argument must be an existing theme name. Value: `%s`.', oldName ) ); + } + this._themes[ newName ] = this._themes[ oldName ]; + if ( oldName === this._theme ) { + this._repl.settings( 'theme', newName ); + } + this._themes = omit( this._themes, oldName ); +}); + +/** +* Deletes a color theme. +* +* @name deleteTheme +* @memberof SyntaxHighlighter.prototype +* @type {Function} +* @param {Object} theme - theme name +* @throws {Error} must provide an existing theme name +* @throws {Error} must provide a theme other than the default theme +*/ +setNonEnumerableReadOnly( SyntaxHighlighter.prototype, 'deleteTheme', function deleteTheme( theme ) { + if ( !hasOwnProp( this._themes, theme ) ) { + throw new Error( format( 'invalid argument. First argument must be an existing theme name. Value: `%s`.', theme ) ); + } + if ( theme === DEFAULT_THEME ) { + throw new Error( format( 'invalid argument. First argument must not be the default theme name. Value: `%s`.', theme ) ); + } + if ( theme === this._theme ) { + this._highlightedLine = ''; + this._repl.settings( 'theme', DEFAULT_THEME ); + } + this._themes = omit( this._themes, theme ); +}); + +/** +* Disables the syntax-highlighter. +* +* @name disable +* @memberof SyntaxHighlighter.prototype +* @type {Function} +* @returns {SyntaxHighlighter} syntax-highlighter instance +*/ +setNonEnumerableReadOnly( SyntaxHighlighter.prototype, 'disable', function disable() { + this._enabled = false; + return this; +}); + +/** +* Enables the syntax-highlighter. +* +* @name enable +* @memberof SyntaxHighlighter.prototype +* @type {Function} +* @returns {SyntaxHighlighter} syntax-highlighter instance +*/ +setNonEnumerableReadOnly( SyntaxHighlighter.prototype, 'enable', function enable() { + this._enabled = true; + return this; +}); + /** * Callback for handling a "keypress" event. * @@ -136,19 +283,16 @@ setNonEnumerableReadOnly( SyntaxHighlighter.prototype, '_highlightLine', functio * @returns {void} */ setNonEnumerableReadOnly( SyntaxHighlighter.prototype, 'onKeypress', function onKeypress() { - var highlightedLine; var tokens; + if ( !this._enabled ) { + return; + } if ( !this._rli.line ) { debug( 'Empty line detected. Skipping highlighting...' ); return; } - - // If no line change is detected, use the highlighted line from cache... - if ( this._line === this._rli.line ) { - debug( 'No line change detected. Using cache...' ); - highlightedLine = this._highlightedLine; - } else { + if ( this._line !== this._rli.line ) { // Update line buffer: this._line = this._rli.line; @@ -161,16 +305,13 @@ setNonEnumerableReadOnly( SyntaxHighlighter.prototype, 'onKeypress', function on } // Highlight: debug( '%d tokens found. Highlighting...', tokens.length ); - highlightedLine = this._highlightLine( this._line, tokens ); - - // Cache the newly highlighted line: - this._highlightedLine = highlightedLine; + this._highlightedLine = this._highlightLine( this._line, tokens ); } // Replace: debug( 'Replacing current line with the highlighted line...' ); readline.moveCursor( this._ostream, -1 * this._rli.cursor, 0 ); readline.clearLine( this._ostream, 1 ); - this._ostream.write( highlightedLine ); + this._ostream.write( this._highlightedLine ); readline.moveCursor( this._ostream, this._rli.cursor - this._line.length, 0 ); // eslint-disable-line max-len }); diff --git a/lib/themes.js b/lib/themes.js index 5840be5..e49e4f1 100644 --- a/lib/themes.js +++ b/lib/themes.js @@ -28,7 +28,7 @@ * @type {Object} */ var THEMES = { - 'default': { + 'stdlib-ansi-basic': { // Keywords: 'control': 'magenta', 'keyword': 'blue', diff --git a/lib/tokenizer.js b/lib/tokenizer.js index aeee8b2..21ecef7 100644 --- a/lib/tokenizer.js +++ b/lib/tokenizer.js @@ -16,7 +16,7 @@ * limitations under the License. */ -/* eslint-disable max-lines-per-function */ +/* eslint-disable max-lines-per-function, id-length */ 'use strict'; @@ -34,10 +34,12 @@ var commands = require( './commands.js' ); // VARIABLES // var COMMANDS = commands(); +var isUnrecognizedKeyword = contains.factory( [ 'async', 'let' ] ); var isControlKeyword = contains.factory( [ 'if', 'else', 'switch', 'case', 'catch', 'finally', 'try', 'return', 'break', 'continue' ] ); +var isUnrecognizedControlKeyword = contains.factory( [ 'await' ] ); var isSpecialIdentifier = contains.factory( [ 'this', 'super' ] ); var isReservedLiteral = contains.factory( [ 'null', 'true', 'false' ] ); -var isUnrecognizedKeyword = contains.factory( [ 'async', 'await', 'let' ] ); +var isUnrecognizedReservedLiteral = contains.factory( [ 'undefined' ] ); var isStringTokenType = contains.factory( [ 'string', 'template' ] ); var isLiteralType = contains.factory( [ 'string', 'boolean', 'number' ] ); var isReservedName = contains.factory( [ 'undefined', 'async', 'await', 'let' ] ); @@ -104,7 +106,7 @@ function tokenizer( line, context ) { if ( token.start === token.end ) { return; } - if ( token.type.isLoop || isControlKeyword( token.type.keyword ) ) { + if ( token.type.isLoop || isControlKeyword( token.type.keyword ) || ( token.type.label === 'name' && isUnrecognizedControlKeyword( token.value ) ) ) { // Control flow keywords - `for`, `while`, `do`, `if`, `else` etc: token.type = 'control'; tokens.push( token ); @@ -198,11 +200,11 @@ function tokenizer( line, context ) { return true; } // Ignore node if it is an unrecognized `keyword`: - if ( isUnrecognizedKeyword( node.name ) ) { + if ( isUnrecognizedKeyword( node.name ) || isUnrecognizedControlKeyword( node.name ) ) { // eslint-disable-line max-len return true; } // If node is an unrecognized `literal`, push it as a token: - if ( node.name === 'undefined' ) { + if ( isUnrecognizedReservedLiteral( node.name ) ) { tokens.push({ 'value': node.name, 'type': 'literal', @@ -262,8 +264,10 @@ function tokenizer( line, context ) { * * @private * @param {Object} node - AST node + * @param {boolean} compute - boolean indicating whether to compute the `MemberExpression` + * @returns {(void|*)} computed value of the `MemberExpression` if compute is true */ - function resolveMemberExpression( node ) { + function resolveMemberExpression( node, compute ) { var properties = linkedList(); var property; var locals; @@ -291,7 +295,6 @@ function tokenizer( line, context ) { if ( contains( locals, property.value.name ) ) { return; } - // Enter object's namespace: obj = context; properties = properties.iterator(); @@ -303,28 +306,62 @@ function tokenizer( line, context ) { // Fetch properties from context: property = properties.next(); while ( !property.done ) { - obj = obj[ property.value.name ]; - if ( !obj ) { - // Property not found in context: - break; + // Ignore placeholder nodes: + if ( property.value.start === property.value.end ) { + return; } - // Push token if property exists in context: - if ( isLiteralType( typeof obj ) ) { - tokens.push({ - 'value': property.value.name, - 'type': 'variable', - 'start': property.value.start, - 'end': property.value.end - }); - } else { - tokens.push({ - 'value': property.value.name, - 'type': typeof obj, - 'start': property.value.start, - 'end': property.value.end - }); + // Case: 'bar' in `foo['bar']` - property already pushed as a string token. Ignore... + if ( property.value.type === 'Literal' ) { + obj = obj[ property.value.value ]; + if ( !obj ) { + // Property not found in context: + break; + } + property = properties.next(); + continue; + } + // Case: `foo.bar` - resolve property and push it as a token... + if ( property.value.type === 'Identifier' ) { + obj = obj[ property.value.name ]; + if ( !obj ) { + // Property not found in context: + break; + } + if ( !compute ) { + // Push token if property exists in context: + if ( isLiteralType( typeof obj ) ) { + tokens.push({ + 'value': property.value.name, + 'type': 'variable', + 'start': property.value.start, + 'end': property.value.end + }); + } else { + tokens.push({ + 'value': property.value.name, + 'type': typeof obj, + 'start': property.value.start, + 'end': property.value.end + }); + } + } + property = properties.next(); + continue; } - property = properties.next(); + // Case: `foo[a.b].bar` - recursively compute the internal `MemberExpression`... + if ( property.value.type === 'MemberExpression' ) { + obj = obj[ resolveMemberExpression( property.value, true ) ]; + if ( !obj ) { + // Property not found in context: + break; + } + property = properties.next(); + continue; + } + break; + } + if ( compute ) { + return obj; } /** diff --git a/lib/validate.js b/lib/validate.js index adaeee1..11ce529 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -107,6 +107,12 @@ function validate( opts, options ) { return new TypeError( format( 'invalid option. `%s` option must be a string. Option: `%s`.', 'welcome', options.welcome ) ); } } + if ( hasOwnProp( options, 'themes' ) ) { + opts.themes = options.themes; + if ( !isPlainObject( options.themes ) ) { + return new TypeError( format( 'invalid option. `%s` option must be an object. Option: `%s`.', 'themes', options.themes ) ); + } + } if ( hasOwnProp( options, 'save' ) ) { opts.save = options.save; if ( !isString( options.save ) ) { diff --git a/test/integration/fixtures/syntax-highlighting/comments.json b/test/integration/fixtures/syntax-highlighting/comments.json new file mode 100644 index 0000000..aa8f8d2 --- /dev/null +++ b/test/integration/fixtures/syntax-highlighting/comments.json @@ -0,0 +1,7 @@ +{ + "expression": "var a; // This is a comment", + "expected": "var a; \u001b[31m// This is a comment\u001b[0m", + "theme": { + "comment": "red" + } +} diff --git a/test/integration/fixtures/syntax-highlighting/control_keywords.json b/test/integration/fixtures/syntax-highlighting/control_keywords.json new file mode 100644 index 0000000..7baaf3f --- /dev/null +++ b/test/integration/fixtures/syntax-highlighting/control_keywords.json @@ -0,0 +1,7 @@ +{ + "expression": "async function foo(a) { if (a) { return a; } else { try { switch(a) { case 0: return await Promise.resolve(a); } } catch (e) { return e; } } }", + "expected": "async function foo(a) { \u001b[31mif\u001b[0m (a) { \u001b[31mreturn\u001b[0m a; } \u001b[31melse\u001b[0m { \u001b[31mtry\u001b[0m { \u001b[31mswitch\u001b[0m(a) { \u001b[31mcase\u001b[0m 0: \u001b[31mreturn\u001b[0m \u001b[31mawait\u001b[0m Promise.resolve(a); } } \u001b[31mcatch\u001b[0m (e) { \u001b[31mreturn\u001b[0m e; } } }", + "theme": { + "control": "red" + } +} diff --git a/test/integration/fixtures/syntax-highlighting/declaration_names.json b/test/integration/fixtures/syntax-highlighting/declaration_names.json new file mode 100644 index 0000000..cde8e9e --- /dev/null +++ b/test/integration/fixtures/syntax-highlighting/declaration_names.json @@ -0,0 +1,7 @@ +{ + "expression": "let a; function b(x) => 4; class d", + "expected": "let \u001b[31ma\u001b[0m; function \u001b[31mb\u001b[0m(\u001b[31mx\u001b[0m) => 4; class \u001b[31md\u001b[0m", + "theme": { + "name": "red" + } +} diff --git a/test/integration/fixtures/syntax-highlighting/functions.json b/test/integration/fixtures/syntax-highlighting/functions.json new file mode 100644 index 0000000..be9648b --- /dev/null +++ b/test/integration/fixtures/syntax-highlighting/functions.json @@ -0,0 +1,7 @@ +{ + "expression": "function a() {};\na", + "expected": "\u001b[31ma\u001b[0m", + "theme": { + "function": "red" + } +} diff --git a/test/integration/fixtures/syntax-highlighting/keywords.json b/test/integration/fixtures/syntax-highlighting/keywords.json new file mode 100644 index 0000000..a7e63fe --- /dev/null +++ b/test/integration/fixtures/syntax-highlighting/keywords.json @@ -0,0 +1,7 @@ +{ + "expression": "var a = 4; const b = 5; let a = async () => {}; function foo() { return a; } class", + "expected": "\u001b[31mvar\u001b[0m a = 4; \u001b[31mconst\u001b[0m b = 5; \u001b[31mlet\u001b[0m a = \u001b[31masync\u001b[0m () => {}; \u001b[31mfunction\u001b[0m foo() { return a; } \u001b[31mclass\u001b[0m", + "theme": { + "keyword": "red" + } +} diff --git a/test/integration/fixtures/syntax-highlighting/local_scopes.json b/test/integration/fixtures/syntax-highlighting/local_scopes.json new file mode 100644 index 0000000..99977d6 --- /dev/null +++ b/test/integration/fixtures/syntax-highlighting/local_scopes.json @@ -0,0 +1,7 @@ +{ + "expression": "let a = 4; a", + "expected": "let a = 4; \u001b[31ma\u001b[0m", + "theme": { + "variable": "red" + } +} diff --git a/test/integration/fixtures/syntax-highlighting/member_expressions.json b/test/integration/fixtures/syntax-highlighting/member_expressions.json new file mode 100644 index 0000000..bfab779 --- /dev/null +++ b/test/integration/fixtures/syntax-highlighting/member_expressions.json @@ -0,0 +1,9 @@ +{ + "expression": "var a = { 'b': 'bar' }; var foo = { 'bar': { 'func': function() { return true; } } };\nfoo[a.b].func()", + "expected": "\u001b[31mfoo\u001b[0m[\u001b[31ma\u001b[0m.\u001b[32mb\u001b[0m].\u001b[33mfunc\u001b[0m()", + "theme": { + "object": "red", + "function": "yellow", + "variable": "green" + } +} diff --git a/test/integration/fixtures/syntax-highlighting/numeric_literals.json b/test/integration/fixtures/syntax-highlighting/numeric_literals.json new file mode 100644 index 0000000..ffdf0d7 --- /dev/null +++ b/test/integration/fixtures/syntax-highlighting/numeric_literals.json @@ -0,0 +1,7 @@ +{ + "expression": "34 + 45.78", + "expected": "\u001b[31m34\u001b[0m + \u001b[31m45.78\u001b[0m", + "theme": { + "number": "red" + } +} diff --git a/test/integration/fixtures/syntax-highlighting/objects.json b/test/integration/fixtures/syntax-highlighting/objects.json new file mode 100644 index 0000000..798e654 --- /dev/null +++ b/test/integration/fixtures/syntax-highlighting/objects.json @@ -0,0 +1,7 @@ +{ + "expression": "var a = { 'b': 4 };\na", + "expected": "\u001b[31ma\u001b[0m", + "theme": { + "object": "red" + } +} diff --git a/test/integration/fixtures/syntax-highlighting/operators.json b/test/integration/fixtures/syntax-highlighting/operators.json new file mode 100644 index 0000000..fe63210 --- /dev/null +++ b/test/integration/fixtures/syntax-highlighting/operators.json @@ -0,0 +1,7 @@ +{ + "expression": "var a = 5 + 10 * 2 / (4 - 2) == 15 && 10 > 5 || 5 < 10; var b = 5; b++", + "expected": "var a \u001b[31m=\u001b[0m 5 \u001b[31m+\u001b[0m 10 \u001b[31m*\u001b[0m 2 \u001b[31m/\u001b[0m (4 \u001b[31m-\u001b[0m 2) \u001b[31m==\u001b[0m 15 \u001b[31m&&\u001b[0m 10 \u001b[31m>\u001b[0m 5 \u001b[31m||\u001b[0m 5 \u001b[31m<\u001b[0m 10; var b \u001b[31m=\u001b[0m 5; b\u001b[31m++\u001b[0m", + "theme": { + "operator": "red" + } +} diff --git a/test/integration/fixtures/syntax-highlighting/punctuation.json b/test/integration/fixtures/syntax-highlighting/punctuation.json new file mode 100644 index 0000000..4023f76 --- /dev/null +++ b/test/integration/fixtures/syntax-highlighting/punctuation.json @@ -0,0 +1,7 @@ +{ + "expression": "let obj = {a: 1, b: 2}; let func = (x) => x * 2; let ternary = [1, 2] ? true : false", + "expected": "let obj = \u001b[31m{\u001b[0ma\u001b[31m:\u001b[0m 1\u001b[31m,\u001b[0m b\u001b[31m:\u001b[0m 2\u001b[31m}\u001b[0m\u001b[31m;\u001b[0m let func = \u001b[31m(\u001b[0mx\u001b[31m)\u001b[0m \u001b[31m=>\u001b[0m x * 2\u001b[31m;\u001b[0m let ternary = \u001b[31m[\u001b[0m1\u001b[31m,\u001b[0m 2\u001b[31m]\u001b[0m \u001b[31m?\u001b[0m true \u001b[31m:\u001b[0m false", + "theme": { + "punctuation": "red" + } +} diff --git a/test/integration/fixtures/syntax-highlighting/regexp.json b/test/integration/fixtures/syntax-highlighting/regexp.json new file mode 100644 index 0000000..1df483b --- /dev/null +++ b/test/integration/fixtures/syntax-highlighting/regexp.json @@ -0,0 +1,7 @@ +{ + "expression": "/\\d+/g;", + "expected": "\u001b[31m/\\d+/g\u001b[0m;", + "theme": { + "regexp": "red" + } +} diff --git a/test/integration/fixtures/syntax-highlighting/reserved_literals.json b/test/integration/fixtures/syntax-highlighting/reserved_literals.json new file mode 100644 index 0000000..1fd1250 --- /dev/null +++ b/test/integration/fixtures/syntax-highlighting/reserved_literals.json @@ -0,0 +1,7 @@ +{ + "expression": "var a = true; var b = false; var c = null; var d = undefined;", + "expected": "var a = \u001b[31mtrue\u001b[0m; var b = \u001b[31mfalse\u001b[0m; var c = \u001b[31mnull\u001b[0m; var d = \u001b[31mundefined\u001b[0m;", + "theme": { + "literal": "red" + } +} diff --git a/test/integration/fixtures/syntax-highlighting/special_identifiers.json b/test/integration/fixtures/syntax-highlighting/special_identifiers.json new file mode 100644 index 0000000..cf2308d --- /dev/null +++ b/test/integration/fixtures/syntax-highlighting/special_identifiers.json @@ -0,0 +1,7 @@ +{ + "expression": "class Foo { constructor() { this.a = 1; } } class Bar extends Foo { constructor() { super(); this.b = 2; } }", + "expected": "class Foo { constructor() { \u001b[31mthis\u001b[0m.a = 1; } } class Bar extends Foo { constructor() { \u001b[31msuper\u001b[0m(); \u001b[31mthis\u001b[0m.b = 2; } }", + "theme": { + "specialIdentifier": "red" + } +} diff --git a/test/integration/fixtures/syntax-highlighting/string_literals.json b/test/integration/fixtures/syntax-highlighting/string_literals.json new file mode 100644 index 0000000..7b50dba --- /dev/null +++ b/test/integration/fixtures/syntax-highlighting/string_literals.json @@ -0,0 +1,7 @@ +{ + "expression": "var a = 'hello'; var b = `2 + 2 = ${4} haha`;", + "expected": "var a = \u001b[31m'hello'\u001b[0m; var b = `\u001b[31m2 + 2 = \u001b[0m${4}\u001b[31m haha\u001b[0m`;", + "theme": { + "string": "red" + } +} diff --git a/test/integration/fixtures/syntax-highlighting/variables.json b/test/integration/fixtures/syntax-highlighting/variables.json new file mode 100644 index 0000000..2f2df46 --- /dev/null +++ b/test/integration/fixtures/syntax-highlighting/variables.json @@ -0,0 +1,7 @@ +{ + "expression": "var a = true;\na", + "expected": "\u001b[31ma\u001b[0m", + "theme": { + "variable": "red" + } +} diff --git a/test/integration/test.auto_close_pairs.js b/test/integration/test.auto_close_pairs.js index 2da94b4..6a69fdc 100644 --- a/test/integration/test.auto_close_pairs.js +++ b/test/integration/test.auto_close_pairs.js @@ -144,7 +144,8 @@ function defaultSettings() { 'autoClosePairs': false, 'autoDeletePairs': false, 'autoPage': false, - 'completionPreviews': false + 'completionPreviews': false, + 'syntaxHighlighting': false }; } diff --git a/test/integration/test.auto_delete_pairs.js b/test/integration/test.auto_delete_pairs.js index 14b6b65..dd9a839 100644 --- a/test/integration/test.auto_delete_pairs.js +++ b/test/integration/test.auto_delete_pairs.js @@ -112,7 +112,8 @@ function defaultSettings() { 'autoClosePairs': false, 'autoDeletePairs': false, 'autoPage': false, - 'completionPreviews': false + 'completionPreviews': false, + 'syntaxHighlighting': false }; } diff --git a/test/integration/test.auto_page.js b/test/integration/test.auto_page.js index 7c677d2..63703e1 100644 --- a/test/integration/test.auto_page.js +++ b/test/integration/test.auto_page.js @@ -59,6 +59,7 @@ function defaultSettings() { 'autoDeletePairs': false, 'autoClosePairs': false, 'completionPreviews': false, + 'syntaxHighlighting': false, 'autoPage': true }; } diff --git a/test/integration/test.completion_previews.js b/test/integration/test.completion_previews.js index 8aad9ca..caa0c7d 100644 --- a/test/integration/test.completion_previews.js +++ b/test/integration/test.completion_previews.js @@ -45,7 +45,8 @@ tape( 'a REPL instance supports displaying a completion preview of user-defined opts = { 'input': istream, 'settings': { - 'autoPage': false + 'autoPage': false, + 'syntaxHighlighting': false } }; r = repl( opts, onClose ); @@ -88,7 +89,8 @@ tape( 'a REPL instance supports displaying a completion preview for common prefi opts = { 'input': istream, 'settings': { - 'autoPage': false + 'autoPage': false, + 'syntaxHighlighting': false } }; r = repl( opts, onClose ); @@ -132,7 +134,8 @@ tape( 'a REPL instance supports displaying a completion preview for recognized i opts = { 'input': istream, 'settings': { - 'autoPage': false + 'autoPage': false, + 'syntaxHighlighting': false } }; r = repl( opts, onClose ); @@ -175,7 +178,8 @@ tape( 'a REPL instance supports displaying a completion preview when a cursor is opts = { 'input': istream, 'settings': { - 'autoPage': false + 'autoPage': false, + 'syntaxHighlighting': false } }; r = repl( opts, onClose ); @@ -227,7 +231,8 @@ tape( 'a REPL instance supports auto-completing a completion candidate by moving opts = { 'input': istream, 'settings': { - 'autoPage': false + 'autoPage': false, + 'syntaxHighlighting': false } }; r = repl( opts, onClose ); @@ -278,7 +283,8 @@ tape( 'a REPL instance supports auto-completing a completion preview and executi 'inputPrompt': '> ', 'outputPrompt': '', 'settings': { - 'autoPage': false + 'autoPage': false, + 'syntaxHighlighting': false } }; r = repl( opts, onClose ); @@ -330,7 +336,8 @@ tape( 'a REPL instance does not display a completion preview when no completion opts = { 'input': istream, 'settings': { - 'autoPage': false + 'autoPage': false, + 'syntaxHighlighting': false } }; r = repl( opts, onClose ); @@ -387,7 +394,8 @@ tape( 'a REPL instance does not display a completion preview once a user enters 'inputPrompt': '> ', 'outputPrompt': '', 'settings': { - 'autoPage': false + 'autoPage': false, + 'syntaxHighlighting': false } }; r = repl( opts, onClose ); diff --git a/test/integration/test.syntax_highlighting.js b/test/integration/test.syntax_highlighting.js new file mode 100644 index 0000000..71ca8df --- /dev/null +++ b/test/integration/test.syntax_highlighting.js @@ -0,0 +1,203 @@ +/** +* @license Apache-2.0 +* +* Copyright (c) 2024 The Stdlib Authors. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +'use strict'; + +// MODULES // + +var resolve = require( 'path' ).resolve; +var tape = require( 'tape' ); +var DebugStream = require( '@stdlib/streams/node/debug' ); +var replace = require( '@stdlib/string/replace' ); +var readDir = require( '@stdlib/fs/read-dir' ).sync; +var format = require( '@stdlib/string/format' ); +var repl = require( './fixtures/repl.js' ); + + +// VARIABLES // + +var RE_JSON = /\.json$/; + +var FIXTURES_DIR = resolve( __dirname, 'fixtures', 'syntax-highlighting' ); +var FIXTURES_FILES = filter( readDir( FIXTURES_DIR ) ); + + +// FUNCTIONS // + +/** +* Filters a list of files for those having a `*.json` file extension. +* +* @private +* @param {Array} list - file list +* @returns {Array} filtered list +*/ +function filter( list ) { + var out; + var i; + + out = []; + for ( i = 0; i < list.length; i++ ) { + if ( RE_JSON.test( list[ i ] ) ) { + out.push( list[ i ] ); + } + } + return out; +} + +/** +* Returns default settings. +* +* @private +* @returns {Object} default settings +*/ +function defaultSettings() { + return { + 'autoClosePairs': false, + 'autoDeletePairs': false, + 'autoPage': false, + 'completionPreviews': false, + 'syntaxHighlighting': true + }; +} + +/** +* Asserts that a provided expression triggers expected syntax highlighting. +* +* @private +* @param {Object} t - test object +* @param {Object} fixture - fixture object +* @param {string} fixture.expression - expression string +* @param {string} fixture.expected - expected/highlighted string +* @param {Object} fixture.theme - test theme +* @param {Function} done - callback to invoke upon completion +*/ +function assert( t, fixture, done ) { + var expression; + var expected; + var istream; + var opts; + var r; + + istream = new DebugStream({ + 'name': 'repl-input-stream' + }); + opts = { + 'input': istream, + 'inputPrompt': '', + 'quiet': true, + 'settings': defaultSettings() + }; + r = repl( opts, onClose ); + + // Load test theme: + r.addTheme( 'test', fixture.theme ); + r.settings( 'theme', 'test' ); + + // Get expression and expected strings: + expression = fixture.expression; + expected = fixture.expected; + + // Emulate the presence of an existing expression: + istream.write( expression ); + + // Close the input stream: + istream.end(); + + // Close the REPL: + r.close(); + + function onClose( error, data ) { + var actual; + if ( error ) { + t.fail( error.message ); + return; + } + actual = data[ data.length - 1 ]; + t.strictEqual( actual, expected, 'returns expected value' ); + done(); + } +} + +/** +* Generates a test name from a fixture file name. +* +* @private +* @param {string} msg - test description +* @param {string} filename - file name +* @returns {string} test name +*/ +function testName( msg, filename ) { + var str = replace( filename, RE_JSON, '' ); + str = replace( str, '_', ' ' ); + return format( '%s (%s)', msg, str ); +} + +/** +* Returns a test function for testing against a specified fixture file. +* +* @private +* @param {string} fpath - fixture file path +* @param {Function} assert - assertion function +* @returns {Function} test function +*/ +function testFcn( fpath, assert ) { + return test; + + function test( t ) { + var fixture = require( fpath ); // eslint-disable-line stdlib/no-dynamic-require + assert( t, fixture, done ); + + function done() { + t.end(); + } + } +} + +/** +* Run tests against test fixtures. +* +* @private +* @param {string} msg - test description +* @param {string} dir - fixtures directory +* @param {Array} fixtures - list of fixtures +* @param {Function} assert - assert function +*/ +function test( msg, dir, fixtures, assert ) { + var fpath; + var f; + var t; + var i; + + for ( i = 0; i < fixtures.length; i++ ) { + f = fixtures[ i ]; + t = testName( msg, f ); + fpath = resolve( dir, f ); + tape( t, testFcn( fpath, assert ) ); + } +} + + +// TESTS // + +tape( 'main export is a function', function test( t ) { + t.ok( true, __filename ); + t.strictEqual( typeof repl, 'function', 'main export is a function' ); + t.end(); +}); + +test( 'a REPL instance supports syntax highlighting of the input', FIXTURES_DIR, FIXTURES_FILES, assert );