Skip to content

Commit

Permalink
feat: avoid adding multiple lines to SHELL scripts
Browse files Browse the repository at this point in the history
  • Loading branch information
mklabs committed Oct 3, 2018
1 parent 12d5897 commit 036d9c0
Show file tree
Hide file tree
Showing 4 changed files with 230 additions and 50 deletions.
259 changes: 212 additions & 47 deletions lib/installer.js
Expand Up @@ -8,12 +8,26 @@ const readFile = promisify(fs.readFile);
const writeFile = promisify(fs.writeFile);
const { BASH_LOCATION, FISH_LOCATION, ZSH_LOCATION } = require('./constants');

/**
* Little helper to return the correct file extension based on the SHELL script
* location.
*
* @param {String} location - Shell script location
* @returns The correct file extension for the given SHELL script location
*/
const shellExtension = location => {
if (location === BASH_LOCATION) return 'bash';
if (location === FISH_LOCATION) return 'fish';
if (location === ZSH_LOCATION) return 'zsh';
};

/**
* Helper to return the correct script template based on the SHELL script
* location
*
* @param {String} location - Shell script location
* @returns The template script content
*/
const scriptFromLocation = location => {
if (location === BASH_LOCATION) {
return path.join(__dirname, '../scripts/bash.sh');
Expand All @@ -28,38 +42,165 @@ const scriptFromLocation = location => {
}
};

const writeToShellConfig = ({ name, location }) => {
debug(`Adding tabtab script to ${location}`);
const filename = path.join(
/**
* Checks a given file for the existence of a specific line. Used to prevent
* adding multiple completion source to SHELL scripts.
*
* @param {String} filename - The filename to check against
* @param {String} line - The line to look for
* @returns {Boolean} true or false, false if the line is not present.
*/
const checkFilenameForLine = async (filename, line) => {
debug('Check filename (%s) for "%s"', filename, line);

let filecontent = '';
try {
filecontent = await readFile(untildify(filename), 'utf8');
} catch (err) {
if (err.code !== 'ENOENT') {
return console.error(
'Got an error while trying to read from %s file',
filename,
err
);
}
}

return !!filecontent.match(`\n${line}\n`);
};

/**
* Opens a file for modification adding a new `source` line for the given
* SHELL. Used for both SHELL script and tabtab internal one.
*
* @param {Object} options - Options with
* - filename: The file to modify
* - scriptname: The line to add sourcing this file
* - location: The SHELL script location
* - name: The package being configured
* - inShellConfig: Whether it is done in the SHELL script location, used to
* add a few more descriptive lines to the file
*/
const writeLineToFilename = ({
filename,
scriptname,
location,
name,
inShellConfig
}) => (resolve, reject) => {
const stream = fs.createWriteStream(untildify(filename), { flags: 'a' });
stream.on('error', reject);
stream.on('finish', () => resolve());

debug('Writing to shell configuration file (%s)', filename);
debug('scriptname:', scriptname);

if (inShellConfig) {
stream.write(`\n# tabtab source for completion packages`);
stream.write('\n# uninstall by removing these lines');
} else {
stream.write(`\n# tabtab source for ${name} package`);
}

if (location === BASH_LOCATION) {
stream.write(`\n[ -f ${scriptname} ] && . ${scriptname} || true`);
} else if (location === FISH_LOCATION) {
debug('Addding fish line');
stream.write(`\n[ -f ${scriptname} ]; and . ${scriptname}; or true`);
} else if (location === ZSH_LOCATION) {
debug('Addding zsh line');
stream.write(`\n[[ -f ${scriptname} ]] && . ${scriptname} || true`);
}

stream.end('\n');
};

/**
* Writes to SHELL config file adding a new line, but only one, to the SHELL
* config script. This enables tabtab to work for the given SHELL.
*
* @param {Object} options - Options object with
* - location: The SHELL script location (~/.bashrc, ~/.zshrc or
* ~/.config/fish/config.fish)
* - name: The package configured for completion
*/
const writeToShellConfig = async ({ location, name }) => {
const scriptname = path.join(
__dirname,
'../.completions',
`${name}.${shellExtension(location)}`
`__tabtab.${shellExtension(location)}`
);
debug('Which filename', filename);

return new Promise((resolve, reject) => {
const stream = fs.createWriteStream(untildify(location), { flags: 'a' });
stream.on('error', reject);
stream.on('finish', () => resolve());
const filename = location;

debug('Writing to shell configuration file (%s)', location);
stream.write(`\n# tabtab source for ${name} package`);
stream.write('\n# uninstall by removing these lines');
// Check if SHELL script already has a line for tabtab
const existing = await checkFilenameForLine(
filename,
`# tabtab source for completion packages`
);
if (existing) {
return debug('Tabtab line already exists in %s file', filename);
}

if (location === BASH_LOCATION) {
stream.write(`\n[ -f ${filename} ] && . ${filename} || true`);
} else if (location === FISH_LOCATION) {
debug('Addding fish line');
stream.write(`\n[ -f ${filename} ]; and . ${filename}; or true`);
} else if (location === ZSH_LOCATION) {
debug('Addding zsh line');
stream.write(`\n[[ -f ${filename} ]] && . ${filename} || true`);
}
return new Promise(
writeLineToFilename({
filename,
scriptname,
location,
name,
inShellConfig: true
})
);
};

/**
* Writes to tabtab internal script that acts as a frontend router for the
* completion mechanism, in the internal .completions/ directory. Every
* completion is added to this file.
*
* @param {Object} options - Options object with
* - location: The SHELL script location (~/.bashrc, ~/.zshrc or
* ~/.config/fish/config.fish)
* - name: The package configured for completion
*/
const writeToTabtabScript = async ({ name, location }) => {
const filename = path.join(
__dirname,
'../.completions',
`__tabtab.${shellExtension(location)}`
);

const scriptname = path.join(
__dirname,
'../.completions',
`${name}.${shellExtension(location)}`
);

// Check if tabtab completion file already has this line in it
const existing = await checkFilenameForLine(
filename,
`# tabtab source for ${name} package`
);
if (existing) {
return debug('Tabtab line already exists in %s file', filename);
}

stream.end('\n');
});
return new Promise(
writeLineToFilename({ filename, scriptname, location, name })
);
};

/**
* This writes a new completion script in the internal `.completions/`
* directory. Depending on the SHELL used (and the location parameter), a
* different script is created for the given SHELL.
*
* @param {Object} options - Options object with
* - name: The package configured for completion
* - completer: The binary that will act as the completer for `name` program
* - location: The SHELL script location (~/.bashrc, ~/.zshrc or
* ~/.config/fish/config.fish)
*/
const writeToCompletionScript = ({ name, completer, location }) => {
const filename = path.join(
__dirname,
Expand All @@ -82,34 +223,58 @@ const writeToCompletionScript = ({ name, completer, location }) => {
.then(filecontent => writeFile(filename, filecontent));
};

const installer = {
install(options = { name: '', completer: '', location: '' }) {
debug('Install with options', options);
if (!options.name) {
throw new TypeError('options.name is required');
}
/**
* Top level install method. Does three things:
*
* - Writes to SHELL config file, adding a new line to tabtab internal script.
* - Creates or edit tabtab internal script
* - Creates the actual completion script for this package.
*
* @param {Object} options - Options object with
* - name: The program name to complete
* - completer: The actual program or binary that will act as the completer
* for `name` program. Can be the same.
* - location: The SHELL script config location (~/.bashrc, ~/.zshrc or
* ~/.config/fish/config.fish)
*/
const install = (options = { name: '', completer: '', location: '' }) => {
debug('Install with options', options);
if (!options.name) {
throw new TypeError('options.name is required');
}

if (!options.completer) {
throw new TypeError('options.completer is required');
}
if (!options.completer) {
throw new TypeError('options.completer is required');
}

if (!options.location) {
throw new TypeError('options.location is required');
}
if (!options.location) {
throw new TypeError('options.location is required');
}

return Promise.all([
writeToShellConfig(options),
writeToCompletionScript(options)
]);
},
return Promise.all([
writeToShellConfig(options),
writeToTabtabScript(options),
writeToCompletionScript(options)
]);
};

uninstall(options = { name: '' }) {
debug('Uninstall with options', options);
throw new Error('Not yet implemented');
},
/**
* Not yet implemented. Here the idea is to uninstall a given package
* completion from internal tabtab and / or the SHELL config.
*
* @param {type} name - parameter description...
*/
const uninstall = (options = { name: '' }) => {
debug('Uninstall with options', options);
throw new Error('Not yet implemented');
};

module.exports = {
install,
uninstall,
checkFilenameForLine,
writeToShellConfig,
writeToCompletionScript
writeToTabtabScript,
writeToCompletionScript,
writeLineToFilename
};

module.exports = installer;
2 changes: 1 addition & 1 deletion package.json
Expand Up @@ -4,7 +4,7 @@
"description": "tab completion helpers, for node cli programs. Inspired by npm completion.",
"main": "lib/index.js",
"scripts": {
"test": "DEBUG='tabtab*' TABTAB_DEBUG='./test/tabtab.log' c8 mocha --timeout 5000",
"test": "DEBUG='tabtab*' c8 mocha --timeout 5000",
"posttest": "npm run eslint",
"mocha": "DEBUG='tabtab*' mocha --timeout 5000",
"coverage": "c8 report --reporter=text-lcov | coveralls",
Expand Down
17 changes: 16 additions & 1 deletion test/installer.js
@@ -1,5 +1,6 @@
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const untildify = require('untildify');
const { promisify } = require('util');
const {
Expand Down Expand Up @@ -50,8 +51,22 @@ describe('installer', () => {
})
.then(() => readFile(untildify('~/.bashrc'), 'utf8'))
.then(filecontent => {
assert.ok(/tabtab source for foo/.test(filecontent));
assert.ok(/tabtab source for completion packages/.test(filecontent));
assert.ok(/uninstall by removing these lines/.test(filecontent));
assert.ok(
/\[ -f .+__tabtab.bash ] && \. .+tabtab\/.completions\/__tabtab.bash || true/.test(
filecontent
)
);
})
.then(() =>
readFile(
path.join(__dirname, '../.completions/__tabtab.bash'),
'utf8'
)
)
.then(filecontent => {
assert.ok(/tabtab source for foo/.test(filecontent));
assert.ok(
/\[ -f .+foo.bash ] && \. .+tabtab\/.completions\/foo.bash || true/.test(
filecontent
Expand Down
2 changes: 1 addition & 1 deletion test/tabtab-install.js
Expand Up @@ -78,7 +78,7 @@ describe('tabtab.install()', () => {
})
.then(() => readFile(untildify('~/.bashrc'), 'utf8'))
.then(filecontent => {
assert.ok(/tabtab source for foo/.test(filecontent));
assert.ok(/tabtab source for completion packages/.test(filecontent));
assert.ok(/uninstall by removing these lines/.test(filecontent));
assert.ok(
/\[ -f .+foo.bash ] && \. .+tabtab\/.completions\/foo.bash || true/.test(
Expand Down

0 comments on commit 036d9c0

Please sign in to comment.