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 MediaWiki\Extension\Scribunto\Engines\LuaSandbox\LuaSandboxInterpreter;
7use MediaWiki\Extension\Scribunto\Scribunto;
8use MediaWiki\Extension\Scribunto\ScribuntoContent;
9use MediaWiki\Extension\Scribunto\ScribuntoEngineBase;
10use MediaWiki\Extension\Scribunto\ScribuntoException;
11use MediaWiki\Html\Html;
12use MediaWiki\Json\FormatJson;
13use MediaWiki\MediaWikiServices;
14use MediaWiki\Parser\Parser;
15use MediaWiki\Parser\PPFrame;
16use MediaWiki\Title\Title;
17use RuntimeException;
18use Wikimedia\ScopedCallback;
19
20abstract class LuaEngine extends ScribuntoEngineBase {
21    /**
22     * Libraries to load. See also the 'ScribuntoExternalLibraries' hook.
23     * @var array<string,class-string<LibraryBase>> 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 string[] 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<string,?PPFrame>
63     */
64    protected $currentFrames = [];
65    /**
66     * @var array<string,string>|null
67     */
68    protected $expandCache = [];
69    /**
70     * @var array<string,array|class-string<LibraryBase>>
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<string,callable> $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|string[] $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|class-string<LibraryBase> $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            /** @var LibraryBase $class */
524            $class = new $def( $this );
525        } else {
526            if ( !$loadDeferred && !empty( $def['deferLoad'] ) ) {
527                return null;
528            }
529            if ( isset( $def['class'] ) ) {
530                /** @var LibraryBase $class */
531                $class = new $def['class']( $this );
532            } else {
533                throw new RuntimeException( "No class for library \"$name\"" );
534            }
535        }
536        return $class->register();
537    }
538
539    /**
540     * Handler for the loadPHPLibrary() callback. Register the specified
541     * library and return its function table. It's not necessary to cache the
542     * function table in the object instance, since there is caching in a
543     * wrapper on the Lua side.
544     * @internal
545     * @param string $name
546     * @return array
547     */
548    public function loadPHPLibrary( $name ) {
549        $args = func_get_args();
550        $this->checkString( 'loadPHPLibrary', $args, 0 );
551
552        $ret = null;
553        if ( isset( $this->availableLibraries[$name] ) ) {
554            $ret = $this->instantiatePHPLibrary( $name, $this->availableLibraries[$name], true );
555        }
556
557        return [ $ret ];
558    }
559
560    /**
561     * Handler for the loadPackage() callback. Load the specified
562     * module and return its chunk. It's not necessary to cache the resulting
563     * chunk in the object instance, since there is caching in a wrapper on the
564     * Lua side.
565     * @internal
566     * @param string $name
567     * @return array
568     */
569    public function loadPackage( $name ) {
570        $args = func_get_args();
571        $this->checkString( 'loadPackage', $args, 0 );
572
573        # This is what Lua does for its built-in loaders
574        $luaName = str_replace( '.', '/', $name ) . '.lua';
575        $paths = $this->getLibraryPaths( 'lua', self::$libraryPaths );
576        foreach ( $paths as $path ) {
577            $fileName = $this->normalizeModuleFileName( "$path/$luaName" );
578            if ( !file_exists( $fileName ) ) {
579                continue;
580            }
581            $code = file_get_contents( $fileName );
582            $init = $this->interpreter->loadString( $code, "@$luaName" );
583            return [ $init ];
584        }
585
586        $title = Title::newFromText( $name );
587        if ( !$title || !$title->hasContentModel( CONTENT_MODEL_SCRIBUNTO ) ) {
588            return [];
589        }
590
591        $module = $this->fetchModuleFromParser( $title );
592        if ( $module ) {
593            // @phan-suppress-next-line PhanUndeclaredMethod
594            return [ $module->getInitChunk() ];
595        } else {
596            return [];
597        }
598    }
599
600    /**
601     * Helper function for the implementation of frame methods
602     *
603     * @param string $frameId
604     * @return PPFrame
605     *
606     * @throws LuaError
607     */
608    protected function getFrameById( $frameId ) {
609        if ( $frameId === 'empty' ) {
610            return $this->getParser()->getPreprocessor()->newFrame();
611        } elseif ( isset( $this->currentFrames[$frameId] ) ) {
612            // @phan-suppress-next-line PhanTypeMismatchReturnNullable False positive
613            return $this->currentFrames[$frameId];
614        } else {
615            throw new LuaError( 'invalid frame ID' );
616        }
617    }
618
619    /**
620     * Handler for frameExists()
621     *
622     * @internal
623     * @param string $frameId
624     * @return array
625     */
626    public function frameExists( $frameId ) {
627        return [ $frameId === 'empty' || isset( $this->currentFrames[$frameId] ) ];
628    }
629
630    /**
631     * Handler for newChildFrame()
632     *
633     * @internal
634     * @param string $frameId
635     * @param string $title
636     * @param array $args
637     * @return array
638     * @throws LuaError
639     */
640    public function newChildFrame( $frameId, $title, array $args ) {
641        if ( count( $this->currentFrames ) > 100 ) {
642            throw new LuaError( 'newChild: too many frames' );
643        }
644
645        $frame = $this->getFrameById( $frameId );
646        if ( $title === false ) {
647            $title = $frame->getTitle();
648        } else {
649            $title = Title::newFromText( $title );
650            if ( !$title ) {
651                throw new LuaError( 'newChild: invalid title' );
652            }
653        }
654        $args = $this->getParser()->getPreprocessor()->newPartNodeArray( $args );
655        $newFrame = $frame->newChild( $args, $title );
656        $newFrameId = 'frame' . count( $this->currentFrames );
657        $this->currentFrames[$newFrameId] = $newFrame;
658        return [ $newFrameId ];
659    }
660
661    /**
662     * Handler for getTitle()
663     *
664     * @internal
665     * @param string $frameId
666     *
667     * @return array
668     */
669    public function getFrameTitle( $frameId ) {
670        $frame = $this->getFrameById( $frameId );
671        return [ $frame->getTitle()->getPrefixedText() ];
672    }
673
674    /**
675     * Handler for setTTL()
676     * @internal
677     * @param int $ttl
678     */
679    public function setTTL( $ttl ) {
680        $args = func_get_args();
681        $this->checkNumber( 'setTTL', $args, 0 );
682
683        $frame = $this->getFrameById( 'current' );
684        $frame->setTTL( $ttl );
685    }
686
687    /**
688     * Handler for getExpandedArgument()
689     * @internal
690     * @param string $frameId
691     * @param string $name
692     * @return array
693     */
694    public function getExpandedArgument( $frameId, $name ) {
695        $args = func_get_args();
696        $this->checkString( 'getExpandedArgument', $args, 0 );
697
698        $frame = $this->getFrameById( $frameId );
699        $this->getInterpreter()->pauseUsageTimer();
700        $result = $frame->getArgument( $name );
701        if ( $result === false ) {
702            return [];
703        } else {
704            return [ $result ];
705        }
706    }
707
708    /**
709     * Handler for getAllExpandedArguments()
710     * @internal
711     * @param string $frameId
712     * @return array
713     */
714    public function getAllExpandedArguments( $frameId ) {
715        $frame = $this->getFrameById( $frameId );
716        $this->getInterpreter()->pauseUsageTimer();
717        return [ $frame->getArguments() ];
718    }
719
720    /**
721     * Handler for expandTemplate()
722     * @internal
723     * @param string $frameId
724     * @param string $titleText
725     * @param array $args
726     * @return array
727     * @throws LuaError
728     */
729    public function expandTemplate( $frameId, $titleText, $args ) {
730        $frame = $this->getFrameById( $frameId );
731        $title = Title::newFromText( $titleText, NS_TEMPLATE );
732        if ( !$title ) {
733            throw new LuaError( "expandTemplate: invalid title \"$titleText\"" );
734        }
735
736        if ( $frame->depth >= $this->parser->getOptions()->getMaxTemplateDepth() ) {
737            throw new LuaError( 'expandTemplate: template depth limit exceeded' );
738        }
739        if ( MediaWikiServices::getInstance()->getNamespaceInfo()->isNonincludable( $title->getNamespace() ) ) {
740            throw new LuaError( 'expandTemplate: template inclusion denied' );
741        }
742
743        [ $dom, $finalTitle ] = $this->parser->getTemplateDom( $title );
744        if ( $dom === false ) {
745            throw new LuaError( "expandTemplate: template \"$titleText\" does not exist" );
746        }
747        if ( !$frame->loopCheck( $finalTitle ) ) {
748            throw new LuaError( 'expandTemplate: template loop detected' );
749        }
750
751        $fargs = $this->getParser()->getPreprocessor()->newPartNodeArray( $args );
752        $newFrame = $frame->newChild( $fargs, $finalTitle );
753        $text = $this->doCachedExpansion( $newFrame, $dom,
754            [
755                'frameId' => $frameId,
756                'template' => $finalTitle->getPrefixedDBkey(),
757                'args' => $args
758            ] );
759        return [ $text ];
760    }
761
762    /**
763     * Handler for callParserFunction()
764     * @internal
765     * @param string $frameId
766     * @param string $function
767     * @param array $args
768     * @throws LuaError
769     * @return array
770     * @suppress PhanImpossibleCondition
771     */
772    public function callParserFunction( $frameId, $function, $args ) {
773        $frame = $this->getFrameById( $frameId );
774
775        # Make zero-based, without screwing up named args
776        $args = array_merge( [], $args );
777
778        # Sort, since we can't rely on the order coming in from Lua
779        uksort( $args, static function ( $a, $b ) {
780            if ( is_int( $a ) !== is_int( $b ) ) {
781                return is_int( $a ) ? -1 : 1;
782            }
783            if ( is_int( $a ) ) {
784                return $a - $b;
785            }
786            return strcmp( $a, $b );
787        } );
788
789        # Be user-friendly
790        $colonPos = strpos( $function, ':' );
791        if ( $colonPos !== false ) {
792            array_unshift( $args, trim( substr( $function, $colonPos + 1 ) ) );
793            $function = substr( $function, 0, $colonPos );
794        }
795        if ( !isset( $args[0] ) ) {
796            # It's impossible to call a parser function from wikitext without
797            # supplying an arg 0. Insist that one be provided via Lua, too.
798            throw new LuaError( 'callParserFunction: At least one unnamed parameter ' .
799                '(the parameter that comes after the colon in wikitext) ' .
800                'must be provided'
801            );
802        }
803
804        $result = $this->parser->callParserFunction( $frame, $function, $args );
805        if ( !$result['found'] ) {
806            throw new LuaError( "callParserFunction: function \"$function\" was not found" );
807        }
808
809        # Set defaults for various flags
810        $result += [
811            'nowiki' => false,
812            'isChildObj' => false,
813            'isLocalObj' => false,
814            'isHTML' => false,
815            'title' => false,
816        ];
817
818        $text = $result['text'];
819        if ( $result['isChildObj'] ) {
820            $fargs = $this->getParser()->getPreprocessor()->newPartNodeArray( $args );
821            $newFrame = $frame->newChild( $fargs, $result['title'] );
822            if ( $result['nowiki'] ) {
823                $text = $newFrame->expand( $text, PPFrame::RECOVER_ORIG );
824            } else {
825                $text = $newFrame->expand( $text );
826            }
827        }
828        if ( $result['isLocalObj'] && $result['nowiki'] ) {
829            $text = $frame->expand( $text, PPFrame::RECOVER_ORIG );
830            $result['isLocalObj'] = false;
831        }
832
833        # Replace raw HTML by a placeholder
834        if ( $result['isHTML'] ) {
835            $text = $this->parser->insertStripItem( $text );
836        } elseif ( $result['nowiki'] ) {
837            # Escape nowiki-style return values
838            $text = wfEscapeWikiText( $text );
839        }
840
841        if ( $result['isLocalObj'] ) {
842            $text = $frame->expand( $text );
843        }
844
845        return [ "$text" ];
846    }
847
848    /**
849     * Handler for preprocess()
850     * @internal
851     * @param string $frameId
852     * @param string $text
853     * @return array
854     * @throws LuaError
855     */
856    public function preprocess( $frameId, $text ) {
857        $args = func_get_args();
858        $this->checkString( 'preprocess', $args, 0 );
859
860        $frame = $this->getFrameById( $frameId );
861
862        if ( !$frame ) {
863            throw new LuaError( 'attempt to call mw.preprocess with no frame' );
864        }
865
866        // Don't count the time for expanding all the frame arguments against
867        // the Lua time limit.
868        $this->getInterpreter()->pauseUsageTimer();
869        $frame->getArguments();
870        $this->getInterpreter()->unpauseUsageTimer();
871
872        $text = $this->doCachedExpansion( $frame, $text,
873            [
874                'frameId' => $frameId,
875                'inputText' => $text
876            ] );
877        return [ $text ];
878    }
879
880    /**
881     * Increment the expensive function count, and throw if limit exceeded
882     *
883     * @internal
884     * @throws LuaError
885     * @return null
886     */
887    public function incrementExpensiveFunctionCount() {
888        if ( !$this->getParser()->incrementExpensiveFunctionCount() ) {
889            throw new LuaError( "too many expensive function calls" );
890        }
891        return null;
892    }
893
894    /**
895     * Adds a warning to be displayed upon preview
896     *
897     * @internal
898     * @param string $text wikitext
899     */
900    public function addWarning( $text ) {
901        $args = func_get_args();
902        $this->checkString( 'addWarning', $args, 0 );
903
904        // Message localization has to happen on the Lua side
905        $this->getParser()->getOutput()->addWarningMsg(
906            'scribunto-lua-warning',
907            $text
908        );
909    }
910
911    /**
912     * Return whether the parser is currently substing
913     *
914     * @internal
915     * @return array
916     */
917    public function isSubsting() {
918        // See Parser::braceSubstitution, OT_WIKI is the switch
919        return [ $this->getParser()->getOutputType() === Parser::OT_WIKI ];
920    }
921
922    /**
923     * @param PPFrame $frame
924     * @param string|array $input
925     * @param mixed $cacheKey
926     * @return string
927     */
928    private function doCachedExpansion( $frame, $input, $cacheKey ) {
929        $hash = md5( serialize( $cacheKey ) );
930        if ( isset( $this->expandCache[$hash] ) ) {
931            return $this->expandCache[$hash];
932        }
933
934        if ( is_scalar( $input ) ) {
935            $input = str_replace( [ "\r\n", "\r" ], "\n", $input );
936            $dom = $this->parser->getPreprocessor()->preprocessToObj(
937                $input, $frame->depth ? Parser::PTD_FOR_INCLUSION : 0 );
938        } else {
939            $dom = $input;
940        }
941        $ret = $frame->expand( $dom );
942        if ( !$frame->isVolatile() ) {
943            if ( count( $this->expandCache ) > self::MAX_EXPAND_CACHE_SIZE ) {
944                reset( $this->expandCache );
945                $oldHash = key( $this->expandCache );
946                unset( $this->expandCache[$oldHash] );
947            }
948            $this->expandCache[$hash] = $ret;
949        }
950        return $ret;
951    }
952
953    /**
954     * Implements mw.loadJsonData()
955     *
956     * @param string $title Title text, type-checked in Lua
957     * @return string[]
958     */
959    public function loadJsonData( $title ) {
960        $this->incrementExpensiveFunctionCount();
961
962        $titleObj = Title::newFromText( $title );
963        if ( !$titleObj || !$titleObj->exists() || !$titleObj->hasContentModel( CONTENT_MODEL_JSON ) ) {
964            throw new LuaError(
965                "bad argument #1 to 'mw.loadJsonData' ('$title' is not a valid JSON page)"
966            );
967        }
968
969        $parser = $this->getParser();
970        [ $text, $finalTitle ] = $parser->fetchTemplateAndTitle( $titleObj );
971
972        $json = FormatJson::decode( $text, true );
973        if ( is_array( $json ) ) {
974            $json = TextLibrary::reindexArrays( $json, false );
975        }
976        // We'll throw an error for non-tables on the Lua side
977
978        return [ $json ];
979    }
980
981    /**
982     * @see Content::updateRedirect
983     *
984     * @param ScribuntoContent $content
985     * @param Title $target
986     * @return ScribuntoContent
987     */
988    public function updateRedirect( ScribuntoContent $content, Title $target ): ScribuntoContent {
989        if ( !$content->isRedirect() ) {
990            return $content;
991        }
992        return $this->makeRedirectContent( $target );
993    }
994
995    /**
996     * @see Content::getRedirectTarget
997     *
998     * @param ScribuntoContent $content
999     * @return Title|null
1000     */
1001    public function getRedirectTarget( ScribuntoContent $content ) {
1002        $text = $content->getText();
1003        preg_match( '/^return require \[\[(.*?)\]\]/', $text, $matches );
1004        if ( isset( $matches[1] ) ) {
1005            $title = Title::newFromText( $matches[1] );
1006            // Can only redirect to other Scribunto pages
1007            if ( $title && $title->hasContentModel( CONTENT_MODEL_SCRIBUNTO ) ) {
1008                // Have a title, check that the current content equals what
1009                // the redirect content should be
1010                if ( $content->equals( $this->makeRedirectContent( $title ) ) ) {
1011                    return $title;
1012                }
1013            }
1014        }
1015        return null;
1016    }
1017
1018    /**
1019     * @see ContentHandler::makeRedirectContent
1020     * @param Title $destination
1021     * @param string $text
1022     * @return ScribuntoContent
1023     */
1024    public function makeRedirectContent( Title $destination, $text = '' ) {
1025        $targetPage = $destination->getPrefixedText();
1026        $redirectText = "return require [[$targetPage]]";
1027        return new ScribuntoContent( $redirectText );
1028    }
1029
1030    /**
1031     * @see ContentHandler::supportsRedirects
1032     * @return bool true
1033     */
1034    public function supportsRedirects(): bool {
1035        return true;
1036    }
1037}
1038
1039class_alias( LuaEngine::class, 'Scribunto_LuaEngine' );