Merge branch 'MDL-76360-master' of https://github.com/laurentdavid/moodle
[moodle.git] / .grunt / tasks / watch.js
blob25c64876c7143e82f4efca34913eb4ffa518bd15
1 /**
2  * This is a wrapper task to handle the grunt watch command. It attempts to use
3  * Watchman to monitor for file changes, if it's installed, because it's much faster.
4  *
5  * If Watchman isn't installed then it falls back to the grunt-contrib-watch file
6  * watcher for backwards compatibility.
7  */
9 /* eslint-env node */
11 module.exports = grunt => {
12     /**
13      * This is a wrapper task to handle the grunt watch command. It attempts to use
14      * Watchman to monitor for file changes, if it's installed, because it's much faster.
15      *
16      * If Watchman isn't installed then it falls back to the grunt-contrib-watch file
17      * watcher for backwards compatibility.
18      */
19     const watchHandler = function() {
20         const async = require('async');
21         const watchTaskDone = this.async();
22         let watchInitialised = false;
23         let watchTaskQueue = {};
24         let processingQueue = false;
26         const watchman = require('fb-watchman');
27         const watchmanClient = new watchman.Client();
29         // Grab the tasks and files that have been queued up and execute them.
30         var processWatchTaskQueue = function() {
31             if (!Object.keys(watchTaskQueue).length || processingQueue) {
32                 // If there is nothing in the queue or we're already processing then wait.
33                 return;
34             }
36             processingQueue = true;
38             // Grab all tasks currently in the queue.
39             var queueToProcess = watchTaskQueue;
40             // Reset the queue.
41             watchTaskQueue = {};
43             async.forEachSeries(
44                 Object.keys(queueToProcess),
45                 function(task, next) {
46                     var files = queueToProcess[task];
47                     var filesOption = '--files=' + files.join(',');
48                     grunt.log.ok('Running task ' + task + ' for files ' + filesOption);
50                     // Spawn the task in a child process so that it doesn't kill this one
51                     // if it failed.
52                     grunt.util.spawn(
53                         {
54                             // Spawn with the grunt bin.
55                             grunt: true,
56                             // Run from current working dir and inherit stdio from process.
57                             opts: {
58                                 cwd: grunt.moodleEnv.fullRunDir,
59                                 stdio: 'inherit'
60                             },
61                             args: [task, filesOption]
62                         },
63                         function(err, res, code) {
64                             if (code !== 0) {
65                                 // The grunt task failed.
66                                 grunt.log.error(err);
67                             }
69                             // Move on to the next task.
70                             next();
71                         }
72                     );
73                 },
74                 function() {
75                     // No longer processing.
76                     processingQueue = false;
77                     // Once all of the tasks are done then recurse just in case more tasks
78                     // were queued while we were processing.
79                     processWatchTaskQueue();
80                 }
81             );
82         };
84         const originalWatchConfig = grunt.config.get(['watch']);
85         const watchConfig = Object.keys(originalWatchConfig).reduce(function(carry, key) {
86             if (key == 'options') {
87                 return carry;
88             }
90             const value = originalWatchConfig[key];
92             const taskNames = value.tasks;
93             const files = value.files;
94             let excludes = [];
95             if (value.excludes) {
96                 excludes = value.excludes;
97             }
99             taskNames.forEach(function(taskName) {
100                 carry[taskName] = {
101                     files,
102                     excludes,
103                 };
104             });
106             return carry;
107         }, {});
109         watchmanClient.on('error', function(error) {
110             // We have to add an error handler here and parse the error string because the
111             // example way from the docs to check if Watchman is installed doesn't actually work!!
112             // See: https://github.com/facebook/watchman/issues/509
113             if (error.message.match('Watchman was not found')) {
114                 // If watchman isn't installed then we should fallback to the other watch task.
115                 grunt.log.ok('It is recommended that you install Watchman for better performance using the "watch" command.');
117                 // Fallback to the old grunt-contrib-watch task.
118                 grunt.renameTask('watch-grunt', 'watch');
119                 grunt.task.run(['watch']);
120                 // This task is finished.
121                 watchTaskDone(0);
122             } else {
123                 grunt.log.error(error);
124                 // Fatal error.
125                 watchTaskDone(1);
126             }
127         });
129         watchmanClient.on('subscription', function(resp) {
130             if (resp.subscription !== 'grunt-watch') {
131                 return;
132             }
134             resp.files.forEach(function(file) {
135                 grunt.log.ok('File changed: ' + file.name);
137                 var fullPath = grunt.moodleEnv.fullRunDir + '/' + file.name;
138                 Object.keys(watchConfig).forEach(function(task) {
140                     const fileGlobs = watchConfig[task].files;
141                     var match = fileGlobs.some(function(fileGlob) {
142                         return grunt.file.isMatch(`**/${fileGlob}`, fullPath);
143                     });
145                     if (match) {
146                         // If we are watching a subdirectory then the file.name will be relative
147                         // to that directory. However the grunt tasks  expect the file paths to be
148                         // relative to the Gruntfile.js location so let's normalise them before
149                         // adding them to the queue.
150                         var relativePath = fullPath.replace(grunt.moodleEnv.gruntFilePath + '/', '');
151                         if (task in watchTaskQueue) {
152                             if (!watchTaskQueue[task].includes(relativePath)) {
153                                 watchTaskQueue[task] = watchTaskQueue[task].concat(relativePath);
154                             }
155                         } else {
156                             watchTaskQueue[task] = [relativePath];
157                         }
158                     }
159                 });
160             });
162             processWatchTaskQueue();
163         });
165         process.on('SIGINT', function() {
166             // Let the user know that they may need to manually stop the Watchman daemon if they
167             // no longer want it running.
168             if (watchInitialised) {
169                 grunt.log.ok('The Watchman daemon may still be running and may need to be stopped manually.');
170             }
172             process.exit();
173         });
175         // Initiate the watch on the current directory.
176         watchmanClient.command(['watch-project', grunt.moodleEnv.fullRunDir], function(watchError, watchResponse) {
177             if (watchError) {
178                 grunt.log.error('Error initiating watch:', watchError);
179                 watchTaskDone(1);
180                 return;
181             }
183             if ('warning' in watchResponse) {
184                 grunt.log.error('warning: ', watchResponse.warning);
185             }
187             var watch = watchResponse.watch;
188             var relativePath = watchResponse.relative_path;
189             watchInitialised = true;
191             watchmanClient.command(['clock', watch], function(clockError, clockResponse) {
192                 if (clockError) {
193                     grunt.log.error('Failed to query clock:', clockError);
194                     watchTaskDone(1);
195                     return;
196                 }
198                 // Generate the expression query used by watchman.
199                 // Documentation is limited, but see https://facebook.github.io/watchman/docs/expr/allof.html for examples.
200                 // We generate an expression to match any value in the files list of all of our tasks, but excluding
201                 // all value in the  excludes list of that task.
202                 //
203                 // [anyof, [
204                 //      [allof, [
205                 //          [anyof, [
206                 //              ['match', validPath, 'wholename'],
207                 //              ['match', validPath, 'wholename'],
208                 //          ],
209                 //          [not,
210                 //              [anyof, [
211                 //                  ['match', invalidPath, 'wholename'],
212                 //                  ['match', invalidPath, 'wholename'],
213                 //              ],
214                 //          ],
215                 //      ],
216                 var matchWholeName = fileGlob => ['match', fileGlob, 'wholename'];
217                 var matches = Object.keys(watchConfig).map(function(task) {
218                     const matchAll = [];
219                     matchAll.push(['anyof'].concat(watchConfig[task].files.map(matchWholeName)));
221                     if (watchConfig[task].excludes.length) {
222                         matchAll.push(['not', ['anyof'].concat(watchConfig[task].excludes.map(matchWholeName))]);
223                     }
225                     return ['allof'].concat(matchAll);
226                 });
228                 matches = ['anyof'].concat(matches);
230                 var sub = {
231                     expression: matches,
232                     // Which fields we're interested in.
233                     fields: ["name", "size", "type"],
234                     // Add our time constraint.
235                     since: clockResponse.clock
236                 };
238                 if (relativePath) {
239                     /* eslint-disable camelcase */
240                     sub.relative_root = relativePath;
241                 }
243                 watchmanClient.command(['subscribe', watch, 'grunt-watch', sub], function(subscribeError) {
244                     if (subscribeError) {
245                         // Probably an error in the subscription criteria.
246                         grunt.log.error('failed to subscribe: ', subscribeError);
247                         watchTaskDone(1);
248                         return;
249                     }
251                     grunt.log.ok('Listening for changes to files in ' + grunt.moodleEnv.fullRunDir);
252                 });
253             });
254         });
255     };
257     // Rename the grunt-contrib-watch "watch" task because we're going to wrap it.
258     grunt.renameTask('watch', 'watch-grunt');
260     // Register the new watch handler.
261     grunt.registerTask('watch', 'Run tasks on file changes', watchHandler);
263     grunt.config.merge({
264         watch: {
265             options: {
266                 nospawn: true // We need not to spawn so config can be changed dynamically.
267             },
268         },
269     });
271     return watchHandler;