Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
75.00% covered (warning)
75.00%
282 / 376
48.84% covered (danger)
48.84%
21 / 43
CRAP
0.00% covered (danger)
0.00%
0 / 1
LuaEngine
75.20% covered (warning)
75.20%
282 / 375
48.84% covered (danger)
48.84%
21 / 43
335.00
0.00% covered (danger)
0.00%
0 / 1
 newAutodetectEngine
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 newInterpreter
n/a
0 / 0
n/a
0 / 0
0
 newModule
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 newLuaError
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 destroy
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 load
88.24% covered (warning)
88.24%
30 / 34
0.00% covered (danger)
0.00%
0 / 1
5.04
 registerInterface
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getLuaLibDir
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 normalizeModuleFileName
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getPerformanceCharacteristics
n/a
0 / 0
n/a
0 / 0
0
 getInterpreter
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setupCurrentFrames
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 executeModule
66.67% covered (warning)
66.67%
6 / 9
0.00% covered (danger)
0.00%
0 / 1
2.15
 executeFunctionChunk
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getLogBuffer
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 formatHtmlLogs
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 loadLibraryFromFile
81.82% covered (warning)
81.82%
18 / 22
0.00% covered (danger)
0.00%
0 / 1
7.29
 getGeSHiLanguage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCodeEditorLanguage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 runConsole
91.89% covered (success)
91.89%
34 / 37
0.00% covered (danger)
0.00%
0 / 1
5.01
 checkType
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 checkString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 checkNumber
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 instantiatePHPLibrary
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 loadPHPLibrary
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 loadPackage
88.89% covered (warning)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
6.05
 getFrameById
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 frameExists
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 newChildFrame
69.23% covered (warning)
69.23%
9 / 13
0.00% covered (danger)
0.00%
0 / 1
4.47
 getFrameTitle
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setTTL
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getExpandedArgument
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 getAllExpandedArguments
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 expandTemplate
77.27% covered (warning)
77.27%
17 / 22
0.00% covered (danger)
0.00%
0 / 1
6.42
 callParserFunction
66.67% covered (warning)
66.67%
30 / 45
0.00% covered (danger)
0.00%
0 / 1
21.26
 preprocess
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
2.00
 incrementExpensiveFunctionCount
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 addWarning
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 isSubsting
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 doCachedExpansion
75.00% covered (warning)
75.00%
12 / 16
0.00% covered (danger)
0.00%
0 / 1
6.56
 loadJsonData
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 updateRedirect
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getRedirectTarget
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 makeRedirectContent
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 supportsRedirects
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Extension\Scribunto\Engines\LuaCommon;
4
5use Exception;
6use FormatJson;
7use MediaWiki\Extension\Scribunto\Engines\LuaSandbox\LuaSandboxInterpreter;
8use MediaWiki\Extension\Scribunto\Scribunto;
9use MediaWiki\Extension\Scribunto\ScribuntoContent;
10use MediaWiki\Extension\Scribunto\ScribuntoEngineBase;
11use MediaWiki\Extension\Scribunto\ScribuntoException;
12use MediaWiki\Html\Html;
13use MediaWiki\MediaWikiServices;
14use MediaWiki\Title\Title;
15use Parser;
16use PPFrame;
17use RuntimeException;
18use Wikimedia\ScopedCallback;
19
20abstract class LuaEngine extends ScribuntoEngineBase {
21    /**
22     * Libraries to load. See also the 'ScribuntoExternalLibraries' hook.
23     * @var array Maps module names to PHP classes or definition arrays
24     */
25    protected static $libraryClasses = [
26        'mw.site' => SiteLibrary::class,
27        'mw.uri' => UriLibrary::class,
28        'mw.ustring' => UstringLibrary::class,
29        'mw.language' => LanguageLibrary::class,
30        'mw.message' => MessageLibrary::class,
31        'mw.title' => TitleLibrary::class,
32        'mw.text' => TextLibrary::class,
33        'mw.html' => HtmlLibrary::class,
34        'mw.hash' => HashLibrary::class,
35    ];
36
37    /**
38     * Paths for modules that may be loaded from Lua. See also the
39     * 'ScribuntoExternalLibraryPaths' hook.
40     * @var array Paths
41     */
42    protected static $libraryPaths = [
43        '.',
44        'luabit',
45        'ustring',
46    ];
47
48    /** @var bool */
49    protected $loaded = false;
50
51    /**
52     * @var LuaInterpreter|null
53     */
54    protected $interpreter;
55
56    /**
57     * @var array
58     */
59    protected $mw;
60
61    /**
62     * @var array
63     */
64    protected $currentFrames = [];
65    /**
66     * @var array|null
67     */
68    protected $expandCache = [];
69    /**
70     * @var array
71     */
72    protected $availableLibraries = [];
73
74    private const MAX_EXPAND_CACHE_SIZE = 100;
75
76    /**
77     * If luasandbox is installed and usable then use it,
78     * otherwise
79     *
80     * @param array $options
81     * @return LuaEngine
82     */
83    public static function newAutodetectEngine( array $options ) {
84        $engineConf = MediaWikiServices::getInstance()->getMainConfig()->get( 'ScribuntoEngineConf' );
85        $engine = 'luastandalone';
86        try {
87            LuaSandboxInterpreter::checkLuaSandboxVersion();
88            $engine = 'luasandbox';
89        } catch ( LuaInterpreterNotFoundError | LuaInterpreterBadVersionError $e ) {
90            // pass
91        }
92
93        unset( $options['factory'] );
94
95        // @phan-suppress-next-line PhanTypeMismatchReturnSuperType
96        return Scribunto::newEngine( $options + $engineConf[$engine] );
97    }
98
99    /**
100     * Create a new interpreter object
101     * @return LuaInterpreter
102     */
103    abstract protected function newInterpreter();
104
105    /**
106     * @param string $text
107     * @param string|bool $chunkName
108     * @return LuaModule
109     */
110    protected function newModule( $text, $chunkName ) {
111        return new LuaModule( $this, $text, $chunkName );
112    }
113
114    /**
115     * @param string $message
116     * @param array $params
117     * @return LuaError
118     */
119    public function newLuaError( $message, $params = [] ) {
120        return new LuaError( $message, $this->getDefaultExceptionParams() + $params );
121    }
122
123    public function destroy() {
124        // Break reference cycles
125        $this->interpreter = null;
126        $this->mw = [];
127        $this->expandCache = null;
128        parent::destroy();
129    }
130
131    /**
132     * Initialise the interpreter and the base environment
133     */
134    public function load() {
135        if ( $this->loaded ) {
136            return;
137        }
138        $this->loaded = true;
139
140        try {
141            $this->interpreter = $this->newInterpreter();
142
143            $funcs = [
144                'loadPackage',
145                'loadPHPLibrary',
146                'frameExists',
147                'newChildFrame',
148                'getExpandedArgument',
149                'getAllExpandedArguments',
150                'expandTemplate',
151                'callParserFunction',
152                'preprocess',
153                'incrementExpensiveFunctionCount',
154                'isSubsting',
155                'getFrameTitle',
156                'setTTL',
157                'addWarning',
158                'loadJsonData',
159            ];
160
161            $lib = [];
162            foreach ( $funcs as $name ) {
163                $lib[$name] = [ $this, $name ];
164            }
165
166            $this->registerInterface( 'mwInit.lua', [] );
167            $this->mw = $this->registerInterface( 'mw.lua', $lib,
168                [ 'allowEnvFuncs' => $this->options['allowEnvFuncs'] ] );
169
170            $this->availableLibraries = $this->getLibraries( 'lua', self::$libraryClasses );
171            foreach ( $this->availableLibraries as $name => $def ) {
172                $this->instantiatePHPLibrary( $name, $def, false );
173            }
174        } catch ( Exception $ex ) {
175            $this->loaded = false;
176            $this->interpreter = null;
177            throw $ex;
178        }
179    }
180
181    /**
182     * Register a Lua Library
183     *
184     * This should be called from the library's PHP module's register() method.
185     *
186     * The value for $interfaceFuncs is used to populate the mw_interface
187     * global that is defined when the library's Lua module is loaded. Values
188     * must be PHP callables, which will be seen in Lua as functions.
189     *
190     * @param string $moduleFileName The path to the Lua portion of the library
191     *         (absolute, or relative to $this->getLuaLibDir())
192     * @param array $interfaceFuncs Populates mw_interface
193     * @param array $setupOptions Passed to the modules setupInterface() method.
194     * @return array Lua package
195     */
196    public function registerInterface( $moduleFileName, $interfaceFuncs, $setupOptions = [] ) {
197        $this->interpreter->registerLibrary( 'mw_interface', $interfaceFuncs );
198        $moduleFileName = $this->normalizeModuleFileName( $moduleFileName );
199        $package = $this->loadLibraryFromFile( $moduleFileName );
200        if ( !empty( $package['setupInterface'] ) ) {
201            $this->interpreter->callFunction( $package['setupInterface'], $setupOptions );
202        }
203        return $package;
204    }
205
206    /**
207     * Return the base path for Lua modules.
208     * @return string
209     */
210    public function getLuaLibDir() {
211        return __DIR__ . '/lualib';
212    }
213
214    /**
215     * Normalize a lua module to its full path. If path does not look like an
216     * absolute path (i.e. begins with DIRECTORY_SEPARATOR or "X:"), prepend
217     * getLuaLibDir()
218     *
219     * @param string $fileName name of the lua module file
220     * @return string
221     */
222    protected function normalizeModuleFileName( $fileName ) {
223        if ( !preg_match( '<^(?:[a-zA-Z]:)?' . preg_quote( DIRECTORY_SEPARATOR ) . '>', $fileName ) ) {
224            $fileName = "{$this->getLuaLibDir()}/{$fileName}";
225        }
226        return $fileName;
227    }
228
229    /**
230     * Get performance characteristics of the Lua engine/interpreter
231     *
232     * phpCallsRequireSerialization: boolean
233     *   whether calls between PHP and Lua functions require (slow)
234     *   serialization of parameters and return values
235     *
236     * @return array
237     */
238    abstract public function getPerformanceCharacteristics();
239
240    /**
241     * Get the current interpreter object
242     * @return LuaInterpreter
243     */
244    public function getInterpreter() {
245        $this->load();
246        return $this->interpreter;
247    }
248
249    /**
250     * Replaces the list of current frames, and return a ScopedCallback that
251     * will reset them when it goes out of scope.
252     *
253     * @param PPFrame|null $frame If null, an empty frame with no parent will be used
254     * @return ScopedCallback
255     */
256    private function setupCurrentFrames( PPFrame $frame = null ) {
257        if ( !$frame ) {
258            $frame = $this->getParser()->getPreprocessor()->newFrame();
259        }
260
261        $oldFrames = $this->currentFrames;
262        $oldExpandCache = $this->expandCache;
263        $this->currentFrames = [
264            'current' => $frame,
265            'parent' => $frame->parent ?? null,
266        ];
267        $this->expandCache = [];
268
269        return new ScopedCallback( function () use ( $oldFrames, $oldExpandCache ) {
270            $this->currentFrames = $oldFrames;
271            $this->expandCache = $oldExpandCache;
272        } );
273    }
274
275    /**
276     * Execute a module chunk in a new isolated environment, and return the specified function
277     * @param mixed $chunk As accepted by LuaInterpreter::callFunction()
278     * @param string $functionName
279     * @param PPFrame|null $frame
280     * @return mixed
281     * @throws ScribuntoException
282     */
283    public function executeModule( $chunk, $functionName, $frame ) {
284        // $resetFrames is a ScopedCallback, so it has a purpose even though it appears unused.
285        $resetFrames = $this->setupCurrentFrames( $frame );
286
287        $retval = $this->getInterpreter()->callFunction(
288            $this->mw['executeModule'], $chunk, $functionName
289        );
290        if ( !$retval[0] ) {
291            // If we get here, it means we asked for an element from the table the module returned,
292            // but it returned something other than a table. In this case, $retval[1] contains the type
293            // of what it did returned, instead of the value we asked for.
294            throw $this->newException(
295                'scribunto-lua-notarrayreturn', [ 'args' => [ $retval[1] ] ]
296            );
297        }
298        return $retval[1];
299    }
300
301    /**
302     * Execute a module function chunk
303     * @param mixed $chunk As accepted by LuaInterpreter::callFunction()
304     * @param PPFrame|null $frame
305     * @return array
306     */
307    public function executeFunctionChunk( $chunk, $frame ) {
308        // $resetFrames is a ScopedCallback, so it has a purpose even though it appears unused.
309        $resetFrames = $this->setupCurrentFrames( $frame );
310
311        return $this->getInterpreter()->callFunction(
312            $this->mw['executeFunction'],
313            $chunk );
314    }
315
316    /**
317     * Get data logged by modules
318     * @return string Logged data
319     */
320    protected function getLogBuffer() {
321        if ( !$this->loaded ) {
322            return '';
323        }
324        try {
325            $log = $this->getInterpreter()->callFunction( $this->mw['getLogBuffer'] );
326            return $log[0];
327        } catch ( ScribuntoException $ex ) {
328            // Probably time expired, ignore it.
329            return '';
330        }
331    }
332
333    /**
334     * Format the logged data for HTML output
335     * @param string $logs Logged data
336     * @param bool $localize Whether to localize the message key
337     * @return string HTML
338     */
339    protected function formatHtmlLogs( $logs, $localize ) {
340        $keyMsg = wfMessage( 'scribunto-limitreport-logs' );
341        if ( !$localize ) {
342            $keyMsg->inLanguage( 'en' )->useDatabase( false );
343        }
344        return Html::openElement( 'tr' ) .
345            Html::rawElement( 'th', [ 'colspan' => 2 ], $keyMsg->parse() ) .
346            Html::closeElement( 'tr' ) .
347            Html::openElement( 'tr' ) .
348            Html::openElement( 'td', [ 'colspan' => 2 ] ) .
349            Html::openElement( 'div', [ 'class' => 'mw-collapsible mw-collapsed' ] ) .
350            Html::element( 'pre', [ 'class' => 'scribunto-limitreport-logs' ], $logs ) .
351            Html::closeElement( 'div' ) .
352            Html::closeElement( 'td' ) .
353            Html::closeElement( 'tr' );
354    }
355
356    /**
357     * Load a library from the given file and execute it in the base environment.
358     * @param string $fileName File name/path to load
359     * @return array|null the export list, or null if there isn't one.
360     */
361    protected function loadLibraryFromFile( $fileName ) {
362        static $cache = null;
363
364        $objectcachefactory = MediaWikiServices::getInstance()->getObjectCacheFactory();
365
366        if ( !$cache ) {
367            $cache = $objectcachefactory->getLocalServerInstance( CACHE_HASH );
368        }
369
370        $mtime = filemtime( $fileName );
371        if ( $mtime === false ) {
372            throw new RuntimeException( 'Lua file does not exist: ' . $fileName );
373        }
374
375        $cacheKey = $cache->makeGlobalKey( __CLASS__, $fileName );
376        $fileData = $cache->get( $cacheKey );
377
378        $code = false;
379        if ( $fileData ) {
380            [ $code, $cachedMtime ] = $fileData;
381            if ( $cachedMtime < $mtime ) {
382                $code = false;
383            }
384        }
385        if ( !$code ) {
386            $code = file_get_contents( $fileName );
387            if ( $code === false ) {
388                throw new RuntimeException( 'Lua file does not exist: ' . $fileName );
389            }
390            $cache->set( $cacheKey, [ $code, $mtime ], 60 * 5 );
391        }
392
393        # Prepending an "@" to the chunk name makes Lua think it is a filename
394        $module = $this->getInterpreter()->loadString( $code, '@' . basename( $fileName ) );
395        $ret = $this->getInterpreter()->callFunction( $module );
396        return $ret[0] ?? null;
397    }
398
399    /** @inheritDoc */
400    public function getGeSHiLanguage() {
401        return 'lua';
402    }
403
404    /** @inheritDoc */
405    public function getCodeEditorLanguage() {
406        return 'lua';
407    }
408
409    /** @inheritDoc */
410    public function runConsole( array $params ) {
411        // $resetFrames is a ScopedCallback, so it has a purpose even though it appears unused.
412        $resetFrames = $this->setupCurrentFrames();
413
414        /**
415         * TODO: provide some means for giving correct line numbers for errors
416         * in console input, and for producing an informative error message
417         * if there is an error in prevQuestions.
418         *
419         * Maybe each console line could be evaluated as a different chunk,
420         * apparently that's what lua.c does.
421         */
422        $code = "return function (__init, exe)\n" .
423            "if not exe then exe = function(...) return true, ... end end\n" .
424            "local p = select(2, exe(__init) )\n" .
425            "__init, exe = nil, nil\n" .
426            "local print = mw.log\n";
427        foreach ( $params['prevQuestions'] as $q ) {
428            if ( substr( $q, 0, 1 ) === '=' ) {
429                $code .= "print(" . substr( $q, 1 ) . ")";
430            } else {
431                $code .= $q;
432            }
433            $code .= "\n";
434        }
435        $code .= "mw.clearLogBuffer()\n";
436        if ( substr( $params['question'], 0, 1 ) === '=' ) {
437            // Treat a statement starting with "=" as a return statement, like in lua.c
438            $code .= "local ret = mw.allToString(" . substr( $params['question'], 1 ) . ")\n" .
439                "return ret, mw.getLogBuffer()\n";
440        } else {
441            $code .= $params['question'] . "\n" .
442                "return nil, mw.getLogBuffer()\n";
443        }
444        $code .= "end\n";
445
446        if ( $params['title']->hasContentModel( CONTENT_MODEL_SCRIBUNTO ) ) {
447            $contentModule = $this->newModule(
448                $params['content'], $params['title']->getPrefixedDBkey() );
449            $contentInit = $contentModule->getInitChunk();
450            $contentExe = $this->mw['executeModule'];
451        } else {
452            $contentInit = $params['content'];
453            $contentExe = null;
454        }
455
456        $consoleModule = $this->newModule(
457            $code,
458            wfMessage( 'scribunto-console-current-src' )->text()
459        );
460        $consoleInit = $consoleModule->getInitChunk();
461        $ret = $this->getInterpreter()->callFunction( $this->mw['executeModule'], $consoleInit, false );
462        $func = $ret[1];
463        $ret = $this->getInterpreter()->callFunction( $func, $contentInit, $contentExe );
464
465        return [
466            'return' => $ret[0] ?? null,
467            'print' => $ret[1] ?? '',
468        ];
469    }
470
471    /**
472     * Workalike for luaL_checktype()
473     *
474     * @param string $funcName The Lua function name, for use in error messages
475     * @param array $args The argument array
476     * @param int $index0 The zero-based argument index
477     * @param string|array $type The allowed type names as given by gettype()
478     * @param string $msgType The type name used in the error message
479     * @throws LuaError
480     */
481    public function checkType( $funcName, $args, $index0, $type, $msgType ) {
482        if ( !is_array( $type ) ) {
483            $type = [ $type ];
484        }
485        if ( !isset( $args[$index0] ) || !in_array( gettype( $args[$index0] ), $type, true ) ) {
486            $index1 = $index0 + 1;
487            throw new LuaError( "bad argument #$index1 to '$funcName' ($msgType expected)" );
488        }
489    }
490
491    /**
492     * Workalike for luaL_checkstring()
493     *
494     * @param string $funcName The Lua function name, for use in error messages
495     * @param array $args The argument array
496     * @param int $index0 The zero-based argument index
497     */
498    public function checkString( $funcName, $args, $index0 ) {
499        $this->checkType( $funcName, $args, $index0, 'string', 'string' );
500    }
501
502    /**
503     * Workalike for luaL_checknumber()
504     *
505     * @param string $funcName The Lua function name, for use in error messages
506     * @param array $args The argument array
507     * @param int $index0 The zero-based argument index
508     */
509    public function checkNumber( $funcName, $args, $index0 ) {
510        $this->checkType( $funcName, $args, $index0, [ 'integer', 'double' ], 'number' );
511    }
512
513    /**
514     * Instantiate and register a library.
515     * @param string $name
516     * @param array|string $def
517     * @param bool $loadDeferred
518     * @return array|null
519     */
520    private function instantiatePHPLibrary( $name, $def, $loadDeferred ) {
521        $def = $this->availableLibraries[$name];
522        if ( is_string( $def ) ) {
523            $class = new $def( $this );
524        } else {
525            if ( !$loadDeferred && !empty( $def['deferLoad'] ) ) {
526                return null;
527            }
528            if ( isset( $def['class'] ) ) {
529                $class = new $def['class']( $this );
530            } else {
531                throw new RuntimeException( "No class for library \"$name\"" );
532            }
533        }
534        return $class->register();
535    }
536
537    /**
538     * Handler for the loadPHPLibrary() callback. Register the specified
539     * library and return its function table. It's not necessary to cache the
540     * function table in the object instance, since there is caching in a
541     * wrapper on the Lua side.
542     * @internal
543     * @param string $name
544     * @return array
545     */
546    public function loadPHPLibrary( $name ) {
547        $args = func_get_args();
548        $this->checkString( 'loadPHPLibrary', $args, 0 );
549
550        $ret = null;
551        if ( isset( $this->availableLibraries[$name] ) ) {
552            $ret = $this->instantiatePHPLibrary( $name, $this->availableLibraries[$name], true );
553        }
554
555        return [ $ret ];
556    }
557
558    /**
559     * Handler for the loadPackage() callback. Load the specified
560     * module and return its chunk. It's not necessary to cache the resulting
561     * chunk in the object instance, since there is caching in a wrapper on the
562     * Lua side.
563     * @internal
564     * @param string $name
565     * @return array
566     */
567    public function loadPackage( $name ) {
568        $args = func_get_args();
569        $this->checkString( 'loadPackage', $args, 0 );
570
571        # This is what Lua does for its built-in loaders
572        $luaName = str_replace( '.', '/', $name ) . '.lua';
573        $paths = $this->getLibraryPaths( 'lua', self::$libraryPaths );
574        foreach ( $paths as $path ) {
575            $fileName = $this->normalizeModuleFileName( "$path/$luaName" );
576            if ( !file_exists( $fileName ) ) {
577                continue;
578            }
579            $code = file_get_contents( $fileName );
580            $init = $this->interpreter->loadString( $code, "@$luaName" );
581            return [ $init ];
582        }
583
584        $title = Title::newFromText( $name );
585        if ( !$title || !$title->hasContentModel( CONTENT_MODEL_SCRIBUNTO ) ) {
586            return [];
587        }
588
589        $module = $this->fetchModuleFromParser( $title );
590        if ( $module ) {
591            // @phan-suppress-next-line PhanUndeclaredMethod
592            return [ $module->getInitChunk() ];
593        } else {
594            return [];
595        }
596    }
597
598    /**
599     * Helper function for the implementation of frame methods
600     *
601     * @param string $frameId
602     * @return PPFrame
603     *
604     * @throws LuaError
605     */
606    protected function getFrameById( $frameId ) {
607        if ( $frameId === 'empty' ) {
608            return $this->getParser()->getPreprocessor()->newFrame();
609        } elseif ( isset( $this->currentFrames[$frameId] ) ) {
610            return $this->currentFrames[$frameId];
611        } else {
612            throw new LuaError( 'invalid frame ID' );
613        }
614    }
615
616    /**
617     * Handler for frameExists()
618     *
619     * @internal
620     * @param string $frameId
621     * @return array
622     */
623    public function frameExists( $frameId ) {
624        return [ $frameId === 'empty' || isset( $this->currentFrames[$frameId] ) ];
625    }
626
627    /**
628     * Handler for newChildFrame()
629     *
630     * @internal
631     * @param string $frameId
632     * @param string $title
633     * @param array $args
634     * @return array
635     * @throws LuaError
636     */
637    public function newChildFrame( $frameId, $title, array $args ) {
638        if ( count( $this->currentFrames ) > 100 ) {
639            throw new LuaError( 'newChild: too many frames' );
640        }
641
642        $frame = $this->getFrameById( $frameId );
643        if ( $title === false ) {
644            $title = $frame->getTitle();
645        } else {
646            $title = Title::newFromText( $title );
647            if ( !$title ) {
648                throw new LuaError( 'newChild: invalid title' );
649            }
650        }
651        $args = $this->getParser()->getPreprocessor()->newPartNodeArray( $args );
652        $newFrame = $frame->newChild( $args, $title );
653        $newFrameId = 'frame' . count( $this->currentFrames );
654        $this->currentFrames[$newFrameId] = $newFrame;
655        return [ $newFrameId ];
656    }
657
658    /**
659     * Handler for getTitle()
660     *
661     * @internal
662     * @param string $frameId
663     *
664     * @return array
665     */
666    public function getFrameTitle( $frameId ) {
667        $frame = $this->getFrameById( $frameId );
668        return [ $frame->getTitle()->getPrefixedText() ];
669    }
670
671    /**
672     * Handler for setTTL()
673     * @internal
674     * @param int $ttl
675     */
676    public function setTTL( $ttl ) {
677        $args = func_get_args();
678        $this->checkNumber( 'setTTL', $args, 0 );
679
680        $frame = $this->getFrameById( 'current' );
681        $frame->setTTL( $ttl );
682    }
683
684    /**
685     * Handler for getExpandedArgument()
686     * @internal
687     * @param string $frameId
688     * @param string $name
689     * @return array
690     */
691    public function getExpandedArgument( $frameId, $name ) {
692        $args = func_get_args();
693        $this->checkString( 'getExpandedArgument', $args, 0 );
694
695        $frame = $this->getFrameById( $frameId );
696        $this->getInterpreter()->pauseUsageTimer();
697        $result = $frame->getArgument( $name );
698        if ( $result === false ) {
699            return [];
700        } else {
701            return [ $result ];
702        }
703    }
704
705    /**
706     * Handler for getAllExpandedArguments()
707     * @internal
708     * @param string $frameId
709     * @return array
710     */
711    public function getAllExpandedArguments( $frameId ) {
712        $frame = $this->getFrameById( $frameId );
713        $this->getInterpreter()->pauseUsageTimer();
714        return [ $frame->getArguments() ];
715    }
716
717    /**
718     * Handler for expandTemplate()
719     * @internal
720     * @param string $frameId
721     * @param string $titleText
722     * @param array $args
723     * @return array
724     * @throws LuaError
725     */
726    public function expandTemplate( $frameId, $titleText, $args ) {
727        $frame = $this->getFrameById( $frameId );
728        $title = Title::newFromText( $titleText, NS_TEMPLATE );
729        if ( !$title ) {
730            throw new LuaError( "expandTemplate: invalid title \"$titleText\"" );
731        }
732
733        if ( $frame->depth >= $this->parser->getOptions()->getMaxTemplateDepth() ) {
734            throw new LuaError( 'expandTemplate: template depth limit exceeded' );
735        }
736        if ( MediaWikiServices::getInstance()->getNamespaceInfo()->isNonincludable( $title->getNamespace() ) ) {
737            throw new LuaError( 'expandTemplate: template inclusion denied' );
738        }
739
740        [ $dom, $finalTitle ] = $this->parser->getTemplateDom( $title );
741        if ( $dom === false ) {
742            throw new LuaError( "expandTemplate: template \"$titleText\" does not exist" );
743        }
744        if ( !$frame->loopCheck( $finalTitle ) ) {
745            throw new LuaError( 'expandTemplate: template loop detected' );
746        }
747
748        $fargs = $this->getParser()->getPreprocessor()->newPartNodeArray( $args );
749        $newFrame = $frame->newChild( $fargs, $finalTitle );
750        $text = $this->doCachedExpansion( $newFrame, $dom,
751            [
752                'frameId' => $frameId,
753                'template' => $finalTitle->getPrefixedDBkey(),
754                'args' => $args
755            ] );
756        return [ $text ];
757    }
758
759    /**
760     * Handler for callParserFunction()
761     * @internal
762     * @param string $frameId
763     * @param string $function
764     * @param array $args
765     * @throws LuaError
766     * @return array
767     * @suppress PhanImpossibleCondition
768     */
769    public function callParserFunction( $frameId, $function, $args ) {
770        $frame = $this->getFrameById( $frameId );
771
772        # Make zero-based, without screwing up named args
773        $args = array_merge( [], $args );
774
775        # Sort, since we can't rely on the order coming in from Lua
776        uksort( $args, static function ( $a, $b ) {
777            if ( is_int( $a ) !== is_int( $b ) ) {
778                return is_int( $a ) ? -1 : 1;
779            }
780            if ( is_int( $a ) ) {
781                return $a - $b;
782            }
783            return strcmp( $a, $b );
784        } );
785
786        # Be user-friendly
787        $colonPos = strpos( $function, ':' );
788        if ( $colonPos !== false ) {
789            array_unshift( $args, trim( substr( $function, $colonPos + 1 ) ) );
790            $function = substr( $function, 0, $colonPos );
791        }
792        if ( !isset( $args[0] ) ) {
793            # It's impossible to call a parser function from wikitext without
794            # supplying an arg 0. Insist that one be provided via Lua, too.
795            throw new LuaError( 'callParserFunction: At least one unnamed parameter ' .
796                '(the parameter that comes after the colon in wikitext) ' .
797                'must be provided'
798            );
799        }
800
801        $result = $this->parser->callParserFunction( $frame, $function, $args );
802        if ( !$result['found'] ) {
803            throw new LuaError( "callParserFunction: function \"$function\" was not found" );
804        }
805
806        # Set defaults for various flags
807        $result += [
808            'nowiki' => false,
809            'isChildObj' => false,
810            'isLocalObj' => false,
811            'isHTML' => false,
812            'title' => false,
813        ];
814
815        $text = $result['text'];
816        if ( $result['isChildObj'] ) {
817            $fargs = $this->getParser()->getPreprocessor()->newPartNodeArray( $args );
818            $newFrame = $frame->newChild( $fargs, $result['title'] );
819            if ( $result['nowiki'] ) {
820                $text = $newFrame->expand( $text, PPFrame::RECOVER_ORIG );
821            } else {
822                $text = $newFrame->expand( $text );
823            }
824        }
825        if ( $result['isLocalObj'] && $result['nowiki'] ) {
826            $text = $frame->expand( $text, PPFrame::RECOVER_ORIG );
827            $result['isLocalObj'] = false;
828        }
829
830        # Replace raw HTML by a placeholder
831        if ( $result['isHTML'] ) {
832            $text = $this->parser->insertStripItem( $text );
833        } elseif ( $result['nowiki'] ) {
834            # Escape nowiki-style return values
835            $text = wfEscapeWikiText( $text );
836        }
837
838        if ( $result['isLocalObj'] ) {
839            $text = $frame->expand( $text );
840        }
841
842        return [ "$text" ];
843    }
844
845    /**
846     * Handler for preprocess()
847     * @internal
848     * @param string $frameId
849     * @param string $text
850     * @return array
851     * @throws LuaError
852     */
853    public function preprocess( $frameId, $text ) {
854        $args = func_get_args();
855        $this->checkString( 'preprocess', $args, 0 );
856
857        $frame = $this->getFrameById( $frameId );
858
859        if ( !$frame ) {
860            throw new LuaError( 'attempt to call mw.preprocess with no frame' );
861        }
862
863        // Don't count the time for expanding all the frame arguments against
864        // the Lua time limit.
865        $this->getInterpreter()->pauseUsageTimer();
866        $frame->getArguments();
867        $this->getInterpreter()->unpauseUsageTimer();
868
869        $text = $this->doCachedExpansion( $frame, $text,
870            [
871                'frameId' => $frameId,
872                'inputText' => $text
873            ] );
874        return [ $text ];
875    }
876
877    /**
878     * Increment the expensive function count, and throw if limit exceeded
879     *
880     * @internal
881     * @throws LuaError
882     * @return null
883     */
884    public function incrementExpensiveFunctionCount() {
885        if ( !$this->getParser()->incrementExpensiveFunctionCount() ) {
886            throw new LuaError( "too many expensive function calls" );
887        }
888        return null;
889    }
890
891    /**
892     * Adds a warning to be displayed upon preview
893     *
894     * @internal
895     * @param string $text wikitext
896     */
897    public function addWarning( $text ) {
898        $args = func_get_args();
899        $this->checkString( 'addWarning', $args, 0 );
900
901        // Message localization has to happen on the Lua side
902        $this->getParser()->getOutput()->addWarningMsg(
903            'scribunto-lua-warning',
904            $text
905        );
906    }
907
908    /**
909     * Return whether the parser is currently substing
910     *
911     * @internal
912     * @return array
913     */
914    public function isSubsting() {
915        // See Parser::braceSubstitution, OT_WIKI is the switch
916        return [ $this->getParser()->getOutputType() === Parser::OT_WIKI ];
917    }
918
919    /**
920     * @param PPFrame $frame
921     * @param string|array $input
922     * @param mixed $cacheKey
923     * @return string
924     */
925    private function doCachedExpansion( $frame, $input, $cacheKey ) {
926        $hash = md5( serialize( $cacheKey ) );
927        if ( isset( $this->expandCache[$hash] ) ) {
928            return $this->expandCache[$hash];
929        }
930
931        if ( is_scalar( $input ) ) {
932            $input = str_replace( [ "\r\n", "\r" ], "\n", $input );
933            $dom = $this->parser->getPreprocessor()->preprocessToObj(
934                $input, $frame->depth ? Parser::PTD_FOR_INCLUSION : 0 );
935        } else {
936            $dom = $input;
937        }
938        $ret = $frame->expand( $dom );
939        if ( !$frame->isVolatile() ) {
940            if ( count( $this->expandCache ) > self::MAX_EXPAND_CACHE_SIZE ) {
941                reset( $this->expandCache );
942                $oldHash = key( $this->expandCache );
943                unset( $this->expandCache[$oldHash] );
944            }
945            $this->expandCache[$hash] = $ret;
946        }
947        return $ret;
948    }
949
950    /**
951     * Implements mw.loadJsonData()
952     *
953     * @param string $title Title text, type-checked in Lua
954     * @return string[]
955     */
956    public function loadJsonData( $title ) {
957        $this->incrementExpensiveFunctionCount();
958
959        $titleObj = Title::newFromText( $title );
960        if ( !$titleObj || !$titleObj->exists() || !$titleObj->hasContentModel( CONTENT_MODEL_JSON ) ) {
961            throw new LuaError(
962                "bad argument #1 to 'mw.loadJsonData' ('$title' is not a valid JSON page)"
963            );
964        }
965
966        $parser = $this->getParser();
967        [ $text, $finalTitle ] = $parser->fetchTemplateAndTitle( $titleObj );
968
969        $json = FormatJson::decode( $text, true );
970        if ( is_array( $json ) ) {
971            $json = TextLibrary::reindexArrays( $json, false );
972        }
973        // We'll throw an error for non-tables on the Lua side
974
975        return [ $json ];
976    }
977
978    /**
979     * @see Content::updateRedirect
980     *
981     * @param ScribuntoContent $content
982     * @param Title $target
983     * @return ScribuntoContent
984     */
985    public function updateRedirect( ScribuntoContent $content, Title $target ): ScribuntoContent {
986        if ( !$content->isRedirect() ) {
987            return $content;
988        }
989        return $this->makeRedirectContent( $target );
990    }
991
992    /**
993     * @see Content::getRedirectTarget
994     *
995     * @param ScribuntoContent $content
996     * @return Title|null
997     */
998    public function getRedirectTarget( ScribuntoContent $content ) {
999        $text = $content->getText();
1000        preg_match( '/^return require \[\[(.*?)\]\]/', $text, $matches );
1001        if ( isset( $matches[1] ) ) {
1002            $title = Title::newFromText( $matches[1] );
1003            // Can only redirect to other Scribunto pages
1004            if ( $title && $title->hasContentModel( CONTENT_MODEL_SCRIBUNTO ) ) {
1005                // Have a title, check that the current content equals what
1006                // the redirect content should be
1007                if ( $content->equals( $this->makeRedirectContent( $title ) ) ) {
1008                    return $title;
1009                }
1010            }
1011        }
1012        return null;
1013    }
1014
1015    /**
1016     * @see ContentHandler::makeRedirectContent
1017     * @param Title $destination
1018     * @param string $text
1019     * @return ScribuntoContent
1020     */
1021    public function makeRedirectContent( Title $destination, $text = '' ) {
1022        $targetPage = $destination->getPrefixedText();
1023        $redirectText = "return require [[$targetPage]]";
1024        return new ScribuntoContent( $redirectText );
1025    }
1026
1027    /**
1028     * @see ContentHandler::supportsRedirects
1029     * @return bool true
1030     */
1031    public function supportsRedirects(): bool {
1032        return true;
1033    }
1034}
1035
1036class_alias( LuaEngine::class, 'Scribunto_LuaEngine' );