Implement extension registration from an extension.json file
authorKunal Mehta <legoktm@gmail.com>
Wed, 15 Oct 2014 00:31:15 +0000 (17:31 -0700)
committerTim Starling <tstarling@wikimedia.org>
Thu, 8 Jan 2015 01:40:01 +0000 (01:40 +0000)
Introduces wfLoadExtension()/wfLoadSkin() which should be used in
LocalSettings.php rather than require-ing a PHP entry point.

Extensions and skins would add "extension.json" or "skin.json" files
in their root, which contains all the information typically
present in PHP entry point files (classes to autoload, special pages,
API modules, etc.) A full schema can be found at
docs/extension.schema.json, and a script to validate these to the
schema is provided. An additional script is provided to convert
typical PHP entry point files into their JSON equivalents.

The basic flow of loading an extension goes like:
 * Get the ExtensionRegistry singleton instance
 * ExtensionRegistry takes a filename, reads the file or tries
   to get the parsed JSON from APC if possible.
 * The JSON is run through a Processor instance,
   which registers things with the appropriate
   global settings.
 * The output of the processor is cached in APC if possible.
 * The extension/skin is marked as loaded in the
   ExtensionRegistry and a callback function is executed
   if one was specified.

For ideal performance, a batch loading method is also provided:
 * The absolute path name to the JSON file is queued
   in the ExtensionRegistry instance.
 * When loadFromQueue() is called, it constructs a hash
   unique to the members of the current queue, and sees
   if the queue has been cached in APC. If not, it processes
   each file individually, and combines the result of each
   Processor into one giant array, which is cached in APC.
 * The giant array then sets various global settings,
   defines constants, and calls callbacks.

To invalidate the cached processed info, by default the mtime
of each JSON file is checked. However that can be slow if you
have a large number of extensions, so you can set $wgExtensionInfoMTime
to the mtime of one file, and `touch` it whenever you update
your extensions.

Change-Id: I7074b65d07c5c7d4e3f1fb0755d74a0b07ed4596

14 files changed:
autoload.php
composer.json
docs/extension.schema.json [new file with mode: 0644]
includes/DefaultSettings.php
includes/GlobalFunctions.php
includes/Setup.php
includes/WebStart.php
includes/registration/ExtensionProcessor.php [new file with mode: 0644]
includes/registration/ExtensionRegistry.php [new file with mode: 0644]
includes/registration/Processor.php [new file with mode: 0644]
maintenance/convertExtensionToRegistration.php [new file with mode: 0644]
maintenance/doMaintenance.php
maintenance/validateRegistrationFile.php [new file with mode: 0644]
tests/phpunit/includes/registration/ExtensionProcessorTest.php [new file with mode: 0644]

index 208ab17..f4935b8 100644 (file)
@@ -244,6 +244,7 @@ $wgAutoloadLocalClasses = array(
        'ContentHandler' => __DIR__ . '/includes/content/ContentHandler.php',
        'ContextSource' => __DIR__ . '/includes/context/ContextSource.php',
        'ContribsPager' => __DIR__ . '/includes/specials/SpecialContributions.php',
+       'ConvertExtensionToRegistration' => __DIR__ . '/maintenance/convertExtensionToRegistration.php',
        'ConvertLinks' => __DIR__ . '/maintenance/convertLinks.php',
        'ConvertUserOptions' => __DIR__ . '/maintenance/convertUserOptions.php',
        'ConverterRule' => __DIR__ . '/languages/ConverterRule.php',
@@ -373,6 +374,8 @@ $wgAutoloadLocalClasses = array(
        'ExplodeIterator' => __DIR__ . '/includes/utils/StringUtils.php',
        'ExportProgressFilter' => __DIR__ . '/maintenance/backup.inc',
        'ExtensionLanguages' => __DIR__ . '/maintenance/language/languages.inc',
+       'ExtensionProcessor' => __DIR__ . '/includes/registration/ExtensionProcessor.php',
+       'ExtensionRegistry' => __DIR__ . '/includes/registration/ExtensionRegistry.php',
        'ExternalStore' => __DIR__ . '/includes/externalstore/ExternalStore.php',
        'ExternalStoreDB' => __DIR__ . '/includes/externalstore/ExternalStoreDB.php',
        'ExternalStoreHttp' => __DIR__ . '/includes/externalstore/ExternalStoreHttp.php',
@@ -888,6 +891,7 @@ $wgAutoloadLocalClasses = array(
        'Preprocessor_DOM' => __DIR__ . '/includes/parser/Preprocessor_DOM.php',
        'Preprocessor_Hash' => __DIR__ . '/includes/parser/Preprocessor_Hash.php',
        'ProcessCacheLRU' => __DIR__ . '/includes/libs/ProcessCacheLRU.php',
+       'Processor' => __DIR__ . '/includes/registration/Processor.php',
        'ProfileSection' => __DIR__ . '/includes/profiler/ProfileSection.php',
        'Profiler' => __DIR__ . '/includes/profiler/Profiler.php',
        'ProfilerOutput' => __DIR__ . '/includes/profiler/output/ProfilerOutput.php',
@@ -1263,6 +1267,7 @@ $wgAutoloadLocalClasses = array(
        'UsersPager' => __DIR__ . '/includes/specials/SpecialListusers.php',
        'UtfNormal' => __DIR__ . '/includes/normal/UtfNormal.php',
        'UzConverter' => __DIR__ . '/languages/classes/LanguageUz.php',
+       'ValidateRegistrationFile' => __DIR__ . '/maintenance/validateRegistrationFile.php',
        'ViewAction' => __DIR__ . '/includes/actions/ViewAction.php',
        'VirtualRESTService' => __DIR__ . '/includes/libs/virtualrest/VirtualRESTService.php',
        'VirtualRESTServiceClient' => __DIR__ . '/includes/libs/virtualrest/VirtualRESTServiceClient.php',
index 5901721..6591ac7 100644 (file)
@@ -24,6 +24,7 @@
                "oojs/oojs-ui": "0.6.1"
        },
        "require-dev": {
+               "justinrainbow/json-schema": "~1.3",
                "phpunit/phpunit": "*"
        },
        "suggest": {
diff --git a/docs/extension.schema.json b/docs/extension.schema.json
new file mode 100644 (file)
index 0000000..4b29872
--- /dev/null
@@ -0,0 +1,609 @@
+{
+       "$schema": "http://json-schema.org/schema#",
+       "description": "MediaWiki extension.json schema",
+       "type": "object",
+       "properties": {
+               "name": {
+                       "type": "string",
+                       "description": "The extension's canonical name."
+               },
+               "info-files": {
+                       "type": "array",
+                       "description": "A list of filenames that should be loaded, in addition to this one"
+               },
+               "type": {
+                       "type": "string",
+                       "description": "The extension's type, as an index to $wgExtensionCredits.",
+                       "default": "other",
+                       "enum": [
+                               "api",
+                               "antispam",
+                               "datavalues",
+                               "media",
+                               "parserhook",
+                               "semantic",
+                               "skin",
+                               "specialpage",
+                               "variable",
+                               "other"
+                       ]
+               },
+               "author": {
+                       "type": [
+                               "string",
+                               "array"
+                       ],
+                       "description": "Extension's authors.",
+                       "items": {
+                               "type": "string"
+                       },
+                       "additionalItems": false
+               },
+               "path": {
+                       "type": "string"
+               },
+               "version": {
+                       "type": "string",
+                       "description": "The version of this release of the extension."
+               },
+               "url": {
+                       "type": "string",
+                       "description": "URL to the homepage for the extension.",
+                       "format": "uri"
+               },
+               "description": {
+                       "type": "string",
+                       "description": "Raw description of the extension."
+               },
+               "descriptionmsg": {
+                       "type": "string",
+                       "description": "Message key for a i18n message describing the extension."
+               },
+               "license-name": {
+                       "type": "string",
+                       "description": "Short identifier for the license under which the extension is released.",
+                       "enum": [
+                               "AFL-1.1",
+                               "AFL-1.2",
+                               "AFL-2.0",
+                               "AFL-2.1",
+                               "AFL-3.0",
+                               "APL-1.0",
+                               "Aladdin",
+                               "ANTLR-PD",
+                               "Apache-1.0",
+                               "Apache-1.1",
+                               "Apache-2.0",
+                               "APSL-1.0",
+                               "APSL-1.1",
+                               "APSL-1.2",
+                               "APSL-2.0",
+                               "Artistic-1.0",
+                               "Artistic-1.0-cl8",
+                               "Artistic-1.0-Perl",
+                               "Artistic-2.0",
+                               "AAL",
+                               "BitTorrent-1.0",
+                               "BitTorrent-1.1",
+                               "BSL-1.0",
+                               "BSD-2-Clause",
+                               "BSD-2-Clause-FreeBSD",
+                               "BSD-2-Clause-NetBSD",
+                               "BSD-3-Clause",
+                               "BSD-3-Clause-Clear",
+                               "BSD-4-Clause",
+                               "BSD-4-Clause-UC",
+                               "CECILL-1.0",
+                               "CECILL-1.1",
+                               "CECILL-2.0",
+                               "CECILL-B",
+                               "CECILL-C",
+                               "ClArtistic",
+                               "CNRI-Python",
+                               "CNRI-Python-GPL-Compatible",
+                               "CPOL-1.02",
+                               "CDDL-1.0",
+                               "CDDL-1.1",
+                               "CPAL-1.0",
+                               "CPL-1.0",
+                               "CATOSL-1.1",
+                               "Condor-1.1",
+                               "CC-BY-1.0",
+                               "CC-BY-2.0",
+                               "CC-BY-2.5",
+                               "CC-BY-3.0",
+                               "CC-BY-ND-1.0",
+                               "CC-BY-ND-2.0",
+                               "CC-BY-ND-2.5",
+                               "CC-BY-ND-3.0",
+                               "CC-BY-NC-1.0",
+                               "CC-BY-NC-2.0",
+                               "CC-BY-NC-2.5",
+                               "CC-BY-NC-3.0",
+                               "CC-BY-NC-ND-1.0",
+                               "CC-BY-NC-ND-2.0",
+                               "CC-BY-NC-ND-2.5",
+                               "CC-BY-NC-ND-3.0",
+                               "CC-BY-NC-SA-1.0",
+                               "CC-BY-NC-SA-2.0",
+                               "CC-BY-NC-SA-2.5",
+                               "CC-BY-NC-SA-3.0",
+                               "CC-BY-SA-1.0",
+                               "CC-BY-SA-2.0",
+                               "CC-BY-SA-2.5",
+                               "CC-BY-SA-3.0",
+                               "CC0-1.0",
+                               "CUA-OPL-1.0",
+                               "D-FSL-1.0",
+                               "WTFPL",
+                               "EPL-1.0",
+                               "eCos-2.0",
+                               "ECL-1.0",
+                               "ECL-2.0",
+                               "EFL-1.0",
+                               "EFL-2.0",
+                               "Entessa",
+                               "ErlPL-1.1",
+                               "EUDatagrid",
+                               "EUPL-1.0",
+                               "EUPL-1.1",
+                               "Fair",
+                               "Frameworx-1.0",
+                               "FTL",
+                               "AGPL-1.0",
+                               "AGPL-3.0",
+                               "GFDL-1.1",
+                               "GFDL-1.2",
+                               "GFDL-1.3",
+                               "GPL-1.0",
+                               "GPL-1.0+",
+                               "GPL-2.0",
+                               "GPL-2.0+",
+                               "GPL-2.0-with-autoconf-exception",
+                               "GPL-2.0-with-bison-exception",
+                               "GPL-2.0-with-classpath-exception",
+                               "GPL-2.0-with-font-exception",
+                               "GPL-2.0-with-GCC-exception",
+                               "GPL-3.0",
+                               "GPL-3.0+",
+                               "GPL-3.0-with-autoconf-exception",
+                               "GPL-3.0-with-GCC-exception",
+                               "LGPL-2.1",
+                               "LGPL-2.1+",
+                               "LGPL-3.0",
+                               "LGPL-3.0+",
+                               "LGPL-2.0",
+                               "LGPL-2.0+",
+                               "gSOAP-1.3b",
+                               "HPND",
+                               "IBM-pibs",
+                               "IPL-1.0",
+                               "Imlib2",
+                               "IJG",
+                               "Intel",
+                               "IPA",
+                               "ISC",
+                               "JSON",
+                               "LPPL-1.3a",
+                               "LPPL-1.0",
+                               "LPPL-1.1",
+                               "LPPL-1.2",
+                               "LPPL-1.3c",
+                               "Libpng",
+                               "LPL-1.02",
+                               "LPL-1.0",
+                               "MS-PL",
+                               "MS-RL",
+                               "MirOS",
+                               "MIT",
+                               "Motosoto",
+                               "MPL-1.0",
+                               "MPL-1.1",
+                               "MPL-2.0",
+                               "MPL-2.0-no-copyleft-exception",
+                               "Multics",
+                               "NASA-1.3",
+                               "Naumen",
+                               "NBPL-1.0",
+                               "NGPL",
+                               "NOSL",
+                               "NPL-1.0",
+                               "NPL-1.1",
+                               "Nokia",
+                               "NPOSL-3.0",
+                               "NTP",
+                               "OCLC-2.0",
+                               "ODbL-1.0",
+                               "PDDL-1.0",
+                               "OGTSL",
+                               "OLDAP-2.2.2",
+                               "OLDAP-1.1",
+                               "OLDAP-1.2",
+                               "OLDAP-1.3",
+                               "OLDAP-1.4",
+                               "OLDAP-2.0",
+                               "OLDAP-2.0.1",
+                               "OLDAP-2.1",
+                               "OLDAP-2.2",
+                               "OLDAP-2.2.1",
+                               "OLDAP-2.3",
+                               "OLDAP-2.4",
+                               "OLDAP-2.5",
+                               "OLDAP-2.6",
+                               "OLDAP-2.7",
+                               "OPL-1.0",
+                               "OSL-1.0",
+                               "OSL-2.0",
+                               "OSL-2.1",
+                               "OSL-3.0",
+                               "OLDAP-2.8",
+                               "OpenSSL",
+                               "PHP-3.0",
+                               "PHP-3.01",
+                               "PostgreSQL",
+                               "Python-2.0",
+                               "QPL-1.0",
+                               "RPSL-1.0",
+                               "RPL-1.1",
+                               "RPL-1.5",
+                               "RHeCos-1.1",
+                               "RSCPL",
+                               "Ruby",
+                               "SAX-PD",
+                               "SGI-B-1.0",
+                               "SGI-B-1.1",
+                               "SGI-B-2.0",
+                               "OFL-1.0",
+                               "OFL-1.1",
+                               "SimPL-2.0",
+                               "Sleepycat",
+                               "SMLNJ",
+                               "SugarCRM-1.1.3",
+                               "SISSL",
+                               "SISSL-1.2",
+                               "SPL-1.0",
+                               "Watcom-1.0",
+                               "NCSA",
+                               "VSL-1.0",
+                               "W3C",
+                               "WXwindows",
+                               "Xnet",
+                               "X11",
+                               "XFree86-1.1",
+                               "YPL-1.0",
+                               "YPL-1.1",
+                               "Zimbra-1.3",
+                               "Zlib",
+                               "ZPL-1.1",
+                               "ZPL-2.0",
+                               "ZPL-2.1",
+                               "Unlicense"
+                       ]
+               },
+               "ResourceLoaderModules": {
+                       "type": "object",
+                       "description": "ResourceLoader modules to register",
+                       "additionalProperties": false,
+                       "patternProperties": {
+                               "^[a-zA-Z0-9\\.]+$": {
+                                       "type": "object",
+                                       "description": "A single ResourceLoader module descriptor",
+                                       "properties": {
+                                               "localBasePath": {
+                                                       "type": "string",
+                                                       "description": "Base path to prepend to all local paths in $options. Defaults to $IP"
+                                               },
+                                               "remoteBasePath": {
+                                                       "type": "string",
+                                                       "description": "Base path to prepend to all remote paths in $options. Defaults to $wgScriptPath"
+                                               },
+                                               "remoteExtPath": {
+                                                       "type": "string",
+                                                       "description": "Equivalent of remoteBasePath, but relative to $wgExtensionAssetsPath"
+                                               },
+                                               "scripts": {
+                                                       "type": "array",
+                                                       "description": "Scripts to always include (array of file paths)",
+                                                       "items": {
+                                                               "type": "string"
+                                                       }
+                                               },
+                                               "languageScripts": {
+                                                       "type": "object",
+                                                       "description": "Scripts to include in specific language contexts (mapping of language code to file path(s))",
+                                                       "patternProperties": {
+                                                               "^[a-zA-Z0-9-]{2,}$": {
+                                                                       "type": [
+                                                                               "string",
+                                                                               "array"
+                                                                       ],
+                                                                       "items": {
+                                                                               "type": "string"
+                                                                       }
+                                                               }
+                                                       }
+                                               },
+                                               "skinScripts": {
+                                                       "type": "object",
+                                                       "description": "Scripts to include in specific skin contexts (mapping of skin name to script(s)",
+                                                       "patternProperties": {
+                                                               ".+": {
+                                                                       "type": [
+                                                                               "string",
+                                                                               "array"
+                                                                       ],
+                                                                       "items": {
+                                                                               "type": "string"
+                                                                       }
+                                                               }
+                                                       }
+                                               },
+                                               "debugScripts": {
+                                                       "type": "array",
+                                                       "description": "Scripts to include in debug contexts",
+                                                       "items": {
+                                                               "type": "string"
+                                                       }
+                                               },
+                                               "loaderScripts": {
+                                                       "type": "array",
+                                                       "description": "Scripts to include in the startup module",
+                                                       "items": {
+                                                               "type": "string"
+                                                       }
+                                               },
+                                               "dependencies": {
+                                                       "type": "array",
+                                                       "description": "Modules which must be loaded before this module",
+                                                       "items": {
+                                                               "type": "string"
+                                                       }
+                                               },
+                                               "styles": {
+                                                       "type": "array",
+                                                       "description": "Styles to always load",
+                                                       "items": {
+                                                               "type": "string"
+                                                       }
+                                               },
+                                               "skinStyles": {
+                                                       "type": "object",
+                                                       "description": "Styles to include in specific skin contexts (mapping of skin name to style(s))",
+                                                       "patternProperties": {
+                                                               ".+": {
+                                                                       "type": [
+                                                                               "string",
+                                                                               "array"
+                                                                       ],
+                                                                       "items": {
+                                                                               "type": "string"
+                                                                       }
+                                                               }
+                                                       }
+                                               },
+                                               "messages": {
+                                                       "type": "array",
+                                                       "description": "Messages to always load",
+                                                       "items": {
+                                                               "type": "string"
+                                                       }
+                                               },
+                                               "group": {
+                                                       "type": "string",
+                                                       "description": "Group which this module should be loaded together with"
+                                               },
+                                               "position": {
+                                                       "type": "string",
+                                                       "description": "Position on the page to load this module at",
+                                                       "enum": [
+                                                               "bottom",
+                                                               "top"
+                                                       ]
+                                               }
+                                       }
+                               }
+                       }
+               },
+               "ResourceLoaderSources": {
+                       "type": "object",
+                       "description": "ResourceLoader sources to register"
+               },
+               "ResourceLoaderLESSVars": {
+                       "type": "object",
+                       "description": "ResourceLoader LESS variables"
+               },
+               "ResourceLoaderLESSFunctions": {
+                       "type": "object",
+                       "description": "ResourceLoader LESS functions"
+               },
+               "ResourceLoaderLESSImportPaths": {
+                       "type": "object",
+                       "description": "ResourceLoader import paths"
+               },
+               "namespaces": {
+                       "type": "object",
+                       "description": "Method to add extra namespaces",
+                       "properties": {
+                               "id": {
+                                       "type": "integer"
+                               },
+                               "constant": {
+                                       "type": "string"
+                               },
+                               "name": {
+                                       "type": "string"
+                               },
+                               "gender": {
+                                       "type": "object",
+                                       "properties": {
+                                               "male": {
+                                                       "type": "string"
+                                               },
+                                               "female": {
+                                                       "type": "string"
+                                               }
+                                       }
+                               },
+                               "subpages": {
+                                       "type": "boolean",
+                                       "default": false
+                               },
+                               "content": {
+                                       "type": "boolean",
+                                       "default": false
+                               },
+                               "defaultcontentmodel": {
+                                       "type": "string"
+                               }
+                       }
+               },
+               "TrackingCategories": {
+                       "type": "array",
+                       "description": "Tracking category message keys"
+               },
+               "DefaultUserOptions": {
+                       "type": "object",
+                       "description": "Default values of user options"
+               },
+               "HiddenPrefs": {
+                       "type": "array",
+                       "description": "Preferences users cannot set"
+               },
+               "GroupPermissions": {
+                       "type": "object",
+                       "description": "Default permissions to give to user groups"
+               },
+               "RevokePermissions": {
+                       "type": "object",
+                       "description": "Default permissions to revoke from user groups"
+               },
+               "ImplicitGroups": {
+                       "type": "array",
+                       "description": "Implicit groups"
+               },
+               "GroupsAddToSelf": {
+                       "type": "object",
+                       "description": "Groups a user can add to themselves"
+               },
+               "GroupsRemoveFromSelf": {
+                       "type": "object",
+                       "description": "Groups a user can remove from themselves"
+               },
+               "AddGroups": {
+                       "type": "object",
+                       "description": "Groups a user can add to users"
+               },
+               "RemoveGroups": {
+                       "type": "object",
+                       "description": "Groups a user can remove from users"
+               },
+               "AvailableRights": {
+                       "type": "array",
+                       "description": "User rights added by the extension"
+               },
+               "ContentHandlers": {
+                       "type": "object",
+                       "description": "Mapping of model ID to class name"
+               },
+               "RateLimits": {
+                       "type": "object",
+                       "description": "Rate limits"
+               },
+               "ParserTestFiles": {
+                       "type": "array",
+                       "description": "Parser test files to run"
+               },
+               "RecentChangesFlags": {
+                       "type": "object",
+                       "description": "Flags (letter symbols) shown on RecentChanges pages"
+               },
+               "ExtensionFunctions": {
+                       "type": [
+                               "array",
+                               "string"
+                       ],
+                       "description": "Function to call after setup has finished"
+               },
+               "ExtensionMessagesFiles": {
+                       "type": "object",
+                       "description": "File paths containing PHP internationalization data"
+               },
+               "MessagesDirs": {
+                       "type": "object",
+                       "description": "Directory paths containing JSON internationalization data"
+               },
+               "ExtensionEntryPointListFiles": {
+                       "type": "object"
+               },
+               "SpecialPages": {
+                       "type": "object",
+                       "description": "SpecialPages implemented in this extension (mapping of page name to class name)"
+               },
+               "SpecialPageGroups": {
+                       "type": "object",
+                       "description": "Mapping of special page name to group it belongs to"
+               },
+               "AutoloadClasses": {
+                       "type": "object"
+               },
+               "Hooks": {
+                       "type": "object",
+                       "description": "Hooks this extension uses (mapping of hook name to callback)"
+               },
+               "JobClasses": {
+                       "type": "object",
+                       "description": "Job types this extension implements (mapping of job type to class name)"
+               },
+               "LogTypes": {
+                       "type": "array",
+                       "description": "List of new log types this extension uses"
+               },
+               "LogRestrictions": {
+                       "type": "object"
+               },
+               "FilterLogTypes": {
+                       "type": "array"
+               },
+               "LogNames": {
+                       "type": "object"
+               },
+               "LogHeaders": {
+                       "type": "object"
+               },
+               "LogActions": {
+                       "type": "object"
+               },
+               "LogActionsHandlers": {
+                       "type": "object"
+               },
+               "Actions": {
+                       "type": "object"
+               },
+               "APIModules": {
+                       "type": "object"
+               },
+               "APIFormatModules": {
+                       "type": "object"
+               },
+               "APIMetaModules": {
+                       "type": "object"
+               },
+               "APIPropModules": {
+                       "type": "object"
+               },
+               "APIListModules": {
+                       "type": "object"
+               },
+               "callback": {
+                       "type": [
+                               "array",
+                               "string"
+                       ],
+                       "description": "A function to be called right after MediaWiki processes this file"
+               },
+               "config": {
+                       "type": "object",
+                       "description": "Configuration options for this extension"
+               }
+       }
+}
index 1884b5f..7e5c857 100644 (file)
@@ -2348,6 +2348,23 @@ $wgClockSkewFudge = 5;
  */
 $wgInvalidateCacheOnLocalSettingsChange = true;
 
+/**
+ * When loading extensions through the extension registration system, this
+ * can be used to invalidate the cache. A good idea would be to set this to
+ * one file, you can just `touch` that one to invalidate the cache
+ *
+ * @par Example:
+ * @code
+ * $wgExtensionInfoMtime = filemtime( "$IP/LocalSettings.php" );
+ * @endcode
+ *
+ * If set to false, the mtime for each individual JSON file will be checked,
+ * which can be slow if a large number of extensions are being loaded.
+ *
+ * @var int|bool
+ */
+$wgExtensionInfoMTime = false;
+
 /** @} */ # end of cache settings
 
 /************************************************************************//**
index a3f0a48..f516759 100644 (file)
@@ -160,6 +160,80 @@ if ( !function_exists( 'hash_equals' ) ) {
 }
 /// @endcond
 
+/**
+ * Load an extension
+ *
+ * This is the closest equivalent to:
+ *   require_once "$IP/extensions/$name/$name.php";
+ * as it will process and load the extension immediately.
+ *
+ * However, batch loading with wfLoadExtensions will
+ * be more performant.
+ *
+ * @param string $name Name of the extension to load
+ * @param string|null $path Absolute path of where to find the extension.json file
+ */
+function wfLoadExtension( $name, $path = null ) {
+       if ( !$path ) {
+               global $IP;
+               $path = "$IP/extensions/$name/extension.json";
+       }
+       ExtensionRegistry::getInstance()->load( $path );
+}
+
+/**
+ * Load multiple extensions at once
+ *
+ * Same as wfLoadExtension, but more efficient if you
+ * are loading multiple extensions.
+ *
+ * If you want to specify custom paths, you should interact with
+ * ExtensionRegistry directly.
+ *
+ * @see wfLoadExtension
+ * @param string[] $exts Array of extension names to load
+ */
+function wfLoadExtensions( array $exts ) {
+       global $IP;
+       $registry = ExtensionRegistry::getInstance();
+       foreach ( $exts as $ext ) {
+               $registry->queue( "$IP/extensions/$ext/extension.json" );
+       }
+
+       $registry->loadFromQueue();
+}
+
+/**
+ * Load a skin
+ *
+ * @see wfLoadExtension
+ * @param string $name Name of the extension to load
+ * @param string|null $path Absolute path of where to find the skin.json file
+ */
+function wfLoadSkin( $name, $path = null ) {
+       if ( !$path ) {
+               global $IP;
+               $path = "$IP/skins/$name/skin.json";
+       }
+       ExtensionRegistry::getInstance()->load( $path );
+}
+
+/**
+ * Load multiple skins at once
+ *
+ * @see wfLoadExtensions
+ * @param string[] $skins Array of extension names to load
+ */
+function wfLoadSkins( array $skins ) {
+       global $IP;
+       $registry = ExtensionRegistry::getInstance();
+       foreach ( $skins as $skin ) {
+               $registry->queue( "$IP/skins/$skin/skin.json" );
+       }
+
+       $registry->loadFromQueue();
+}
+
 /**
  * Like array_diff( $a, $b ) except that it works with two-dimensional arrays.
  * @param array $a
index 535b13d..106267a 100644 (file)
@@ -34,6 +34,10 @@ if ( !defined( 'MEDIAWIKI' ) ) {
 
 $fname = 'Setup.php';
 wfProfileIn( $fname );
+
+// If any extensions are still queued, force load them
+ExtensionRegistry::getInstance()->loadFromQueue();
+
 wfProfileIn( $fname . '-defaults' );
 
 // Check to see if we are at the file scope
@@ -479,7 +483,6 @@ wfProfileOut( $fname . '-exception' );
 
 wfProfileIn( $fname . '-includes' );
 require_once "$IP/includes/normal/UtfNormalUtil.php";
-require_once "$IP/includes/GlobalFunctions.php";
 require_once "$IP/includes/normal/UtfNormalDefines.php";
 wfProfileOut( $fname . '-includes' );
 
index 217ba3f..bc1ca50 100644 (file)
@@ -79,6 +79,9 @@ wfProfileIn( 'WebStart.php-conf' );
 # Load default settings
 require_once "$IP/includes/DefaultSettings.php";
 
+# Load global functions
+require_once "$IP/includes/GlobalFunctions.php";
+
 # Load composer's autoloader if present
 if ( is_readable( "$IP/vendor/autoload.php" ) ) {
        require_once "$IP/vendor/autoload.php";
diff --git a/includes/registration/ExtensionProcessor.php b/includes/registration/ExtensionProcessor.php
new file mode 100644 (file)
index 0000000..f42e9f3
--- /dev/null
@@ -0,0 +1,259 @@
+<?php
+
+class ExtensionProcessor implements Processor {
+
+       /**
+        * Keys that should be set to $GLOBALS
+        *
+        * @var array
+        */
+       protected static $globalSettings = array(
+               'ResourceLoaderSources',
+               'ResourceLoaderLESSVars',
+               'ResourceLoaderLESSImportPaths',
+               'TrackingCategories',
+               'DefaultUserOptions',
+               'HiddenPrefs',
+               'GroupPermissions',
+               'RevokePermissions',
+               'ImplicitGroups',
+               'GroupsAddToSelf',
+               'GroupsRemoveFromSelf',
+               'AddGroups',
+               'RemoveGroups',
+               'AvailableRights',
+               'ContentHandlers',
+               'RateLimits',
+               'ParserTestFiles',
+               'RecentChangesFlags',
+               'ExtensionFunctions',
+               'ExtensionEntryPointListFiles',
+               'SpecialPages',
+               'SpecialPageGroups',
+               'JobClasses',
+               'LogTypes',
+               'LogRestrictions',
+               'FilterLogTypes',
+               'LogNames',
+               'LogHeaders',
+               'LogActions',
+               'LogActionsHandlers',
+               'Actions',
+               'APIModules',
+               'APIFormatModules',
+               'APIMetaModules',
+               'APIPropModules',
+               'APIListModules',
+       );
+
+       /**
+        * Keys that are part of the extension credits
+        *
+        * @var array
+        */
+       protected static $creditsAttributes = array(
+               'name',
+               'author',
+               'version',
+               'url',
+               'description',
+               'descriptionmsg',
+               'license-name',
+       );
+
+       /**
+        * Stuff that is going to be set to $GLOBALS
+        *
+        * Some keys are pre-set to arrays so we can += to them
+        *
+        * @var array
+        */
+       protected $globals = array(
+               'wgExtensionMessagesFiles' => array(),
+               'wgMessagesDirs' => array(),
+       );
+
+       /**
+        * Things that should be define()'d
+        *
+        * @var array
+        */
+       protected $defines = array();
+
+       /**
+        * Things to be called once registration of these extensions are done
+        *
+        * @var callable[]
+        */
+       protected $callbacks = array();
+
+       /**
+        * @var array
+        */
+       protected $credits = array();
+
+       /**
+        * Any thing else in the $info that hasn't
+        * already been processed
+        *
+        * @var array
+        */
+       protected $attributes = array();
+
+       /**
+        * List of keys that have already been processed
+        *
+        * @var array
+        */
+       protected $processed = array();
+
+       /**
+        * @param string $path
+        * @param array $info
+        * @return array
+        */
+       public function extractInfo( $path, array $info ) {
+               $this->extractConfig( $info );
+               $this->extractHooks( $info );
+               $dir = dirname( $path );
+               $this->extractMessageSettings( $dir, $info );
+               $this->extractNamespaces( $info );
+               $this->extractResourceLoaderModules( $dir, $info );
+               if ( isset( $info['callback'] ) ) {
+                       $this->callbacks[] = $info['callback'];
+                       $this->processed[] = 'callback';
+               }
+
+               $this->extractCredits( $path, $info );
+               foreach ( $info as $key => $val ) {
+                       if ( in_array( $key, self::$globalSettings ) ) {
+                               $this->storeToArray( "wg$key", $val, $this->globals );
+                       } elseif ( !in_array( $key, $this->processed ) ) {
+                               $this->storeToArray( $key, $val, $this->attributes );
+                       }
+               }
+
+
+
+       }
+
+       public function getExtractedInfo() {
+               return array(
+                       'globals' => $this->globals,
+                       'defines' => $this->defines,
+                       'callbacks' => $this->callbacks,
+                       'credits' => $this->credits,
+                       'attributes' => $this->attributes,
+               );
+       }
+
+       protected function extractHooks( array $info ) {
+               if ( isset( $info['Hooks'] ) ) {
+                       foreach ( $info['Hooks'] as $name => $callable ) {
+                               $this->globals['wgHooks'][$name][] = $callable;
+                       }
+                       $this->processed[] = 'Hooks';
+               }
+       }
+
+       /**
+        * Register namespaces with the appropriate global settings
+        *
+        * @param array $info
+        */
+       protected function extractNamespaces( array $info ) {
+               if ( isset( $info['namespaces'] ) ) {
+                       foreach ( $info['namespaces'] as $ns ) {
+                               $id = $ns['id'];
+                               $this->defines[$ns['constant']] = $id;
+                               $this->globals['wgExtraNamespaces'][$id] = $ns['name'];
+                               if ( isset( $ns['gender'] ) ) {
+                                       $this->globals['wgExtraGenderNamespaces'][$id] = $ns['gender'];
+                               }
+                               if ( isset( $ns['subpages'] ) && $ns['subpages'] ) {
+                                       $this->globals['wgNamespacesWithSubpages'][$id] = true;
+                               }
+                               if ( isset( $ns['content'] ) && $ns['content'] ) {
+                                       $this->globals['wgContentNamespaces'][] = $id;
+                               }
+                               if ( isset( $ns['defaultcontentmodel'] ) ) {
+                                       $this->globals['wgNamespaceContentModels'][$id] = $ns['defaultcontentmodel'];
+                               }
+                       }
+                       $this->processed[] = 'namespaces';
+               }
+       }
+
+       protected function extractResourceLoaderModules( $dir, array $info ) {
+               if ( isset( $info['ResourceModules'] ) ) {
+                       foreach ( $info['ResourceModules'] as $name => $data ) {
+                               if ( isset( $data['localBasePath'] ) ) {
+                                       $data['localBasePath'] = "$dir/{$data['localBasePath']}";
+                               }
+                               $this->globals['wgResourceModules'][$name] = $data;
+                       }
+               }
+       }
+
+       /**
+        * Set message-related settings, which need to be expanded to use
+        * absolute paths
+        *
+        * @param string $dir
+        * @param array $info
+        */
+       protected function extractMessageSettings( $dir, array $info ) {
+               foreach ( array( 'ExtensionMessagesFiles', 'MessagesDirs' ) as $key ) {
+                       if ( isset( $info[$key] ) ) {
+                               $this->globals["wg$key"] += array_map( function( $file ) use ( $dir ) {
+                                       return "$dir/$file";
+                               }, $info[$key] );
+                               $this->processed[] = $key;
+                       }
+               }
+       }
+
+       protected function extractCredits( $path, array $info ) {
+               $credits = array(
+                       'path' => $path,
+                       'type' => isset( $info['type'] ) ? $info['type'] : 'other',
+               );
+               $this->processed[] = 'type';
+               foreach ( self::$creditsAttributes as $attr ) {
+                       if ( isset( $info[$attr] ) ) {
+                               $credits[$attr] = $info[$attr];
+                               $this->processed[] = $attr;
+                       }
+               }
+
+               $this->credits[$credits['name']] = $credits;
+       }
+
+       /**
+        * Set configuration settings
+        * @todo In the future, this should be done via Config interfaces
+        *
+        * @param array $info
+        */
+       protected function extractConfig( array $info ) {
+               if ( isset( $info['config'] ) ) {
+                       foreach ( $info['config'] as $key => $val ) {
+                               $this->globals["wg$key"] = $val;
+                       }
+                       $this->processed[] = 'config';
+               }
+       }
+
+       /**
+        * @param string $name
+        * @param mixed $value
+        * @param array &$array
+        */
+       protected function storeToArray( $name, $value, &$array ) {
+               if ( isset( $array[$name] ) ) {
+                       $array[$name] = array_merge_recursive( $array[$name], $value );
+               } else {
+                       $array[$name] = $value;
+               }
+       }
+}
diff --git a/includes/registration/ExtensionRegistry.php b/includes/registration/ExtensionRegistry.php
new file mode 100644 (file)
index 0000000..44855d8
--- /dev/null
@@ -0,0 +1,260 @@
+<?php
+
+/**
+ * ExtensionRegistry class
+ *
+ * The Registry loads JSON files, and uses a Processor
+ * to extract information from them. It also registers
+ * classes with the autoloader.
+ *
+ * @since 1.25
+ */
+class ExtensionRegistry {
+
+       /**
+        * @var BagOStuff
+        */
+       protected $cache;
+
+       /**
+        * Array of loaded things, keyed by name, values are credits information
+        *
+        * @var array
+        */
+       private $loaded = array();
+
+       /**
+        * List of paths that should be loaded
+        *
+        * @var array
+        */
+       protected $queued = array();
+
+       /**
+        * Items in the JSON file that aren't being
+        * set as globals
+        *
+        * @var array
+        */
+       protected $attributes = array();
+
+       /**
+        * Processors, 'default' should be set by subclasses in the constructor
+        *
+        * @var Processor[]
+        */
+       protected $processors = array();
+
+       /**
+        * @var ExtensionRegistry
+        */
+       private static $instance;
+
+       /**
+        * @return ExtensionRegistry
+        */
+       public static function getInstance() {
+               if ( self::$instance === null ) {
+                       self::$instance = new self();
+               }
+
+               return self::$instance;
+       }
+
+       public function __construct() {
+               $this->cache = ObjectCache::newAccelerator( array(), CACHE_NONE );
+       }
+
+       /**
+        * @param string $path Absolute path to the JSON file
+        */
+       public function queue( $path ) {
+               global $wgExtensionInfoMTime;
+               if ( $wgExtensionInfoMTime !== false ) {
+                       $mtime = $wgExtensionInfoMTime;
+               } else {
+                       $mtime = filemtime( $path );
+               }
+               $this->queued[$path] = $mtime;
+       }
+
+       public function loadFromQueue() {
+               if ( !$this->queued ) {
+                       return;
+               }
+
+               $this->queued = array_unique( $this->queued );
+
+               // See if this queue is in APC
+               $key = wfMemcKey( 'registration', md5( json_encode( $this->queued ) ) );
+               $data = $this->cache->get( $key );
+               if ( $data ) {
+                       $this->exportExtractedData( $data );
+               } else {
+                       $data = array( 'globals' => array( 'wgAutoloadClasses' => array() ) );
+                       $autoloadClasses = array();
+                       foreach ( $this->queued as $path => $mtime ) {
+                               $json = file_get_contents( $path );
+                               $info = json_decode( $json, /* $assoc = */ true );
+                               $autoload = $this->processAutoLoader( dirname( $path ), $info );
+                               // Set up the autoloader now so custom processors will work
+                               $GLOBALS['wgAutoloadClasses'] += $autoload;
+                               $autoloadClasses += $autoload;
+                               if ( isset( $info['processor'] ) ) {
+                                       $processor = $this->getProcessor( $info['processor'] );
+                               } else {
+                                       $processor = $this->getProcessor( 'default' );
+                               }
+                               $processor->extractInfo( $path, $info );
+                       }
+                       foreach ( $this->processors as $processor ) {
+                               $data = array_merge_recursive( $data, $processor->getExtractedInfo() );
+                       }
+                       foreach ( $data['credits'] as $credit ) {
+                               $data['globals']['wgExtensionCredits'][$credit['type']][] = $credit;
+                       }
+                       $this->processors = array(); // Reset
+                       $this->exportExtractedData( $data );
+                       // Do this late since we don't want to extract it since we already
+                       // did that, but it should be cached
+                       $data['globals']['wgAutoloadClasses'] += $autoloadClasses;
+                       $this->cache->set( $key, $data );
+               }
+               $this->queued = array();
+       }
+
+       protected function getProcessor( $type ) {
+               if ( !isset( $this->processors[$type] ) ) {
+                       $processor = $type === 'default' ? new ExtensionProcessor() : new $type();
+                       if ( !$processor instanceof Processor ) {
+                               throw new Exception( "$type is not a Processor" );
+                       }
+                       $this->processors[$type] = $processor;
+               }
+
+               return $this->processors[$type];
+       }
+
+       protected function exportExtractedData( array $info ) {
+               foreach ( $info['globals'] as $key => $val ) {
+                       if ( !isset( $GLOBALS[$key] ) || !$GLOBALS[$key] ) {
+                               $GLOBALS[$key] = $val;
+                       } elseif ( is_array( $GLOBALS[$key] ) && is_array( $val ) ) {
+                               $GLOBALS[$key] = array_merge_recursive( $GLOBALS[$key], $val );
+                       } // else case is a config setting where it has already been overriden, so don't set it
+               }
+               foreach ( $info['defines'] as $name => $val ) {
+                       define( $name, $val );
+               }
+               foreach ( $info['callbacks'] as $cb ) {
+                       call_user_func( $cb );
+               }
+
+               $this->loaded += $info['credits'];
+
+               if ( $info['attributes'] ) {
+                       if ( !$this->attributes ) {
+                               $this->attributes = $info['attributes'];
+                       } else {
+                               $this->attributes = array_merge_recursive( $this->attributes, $info['attributes'] );
+                       }
+               }
+       }
+
+       /**
+        * Loads and processes the given JSON file without delay
+        *
+        * If some extensions are already queued, this will load
+        * those as well.
+        *
+        * @param string $path Absolute path to the JSON file
+        */
+       public function load( $path ) {
+               $this->loadFromQueue(); // First clear the queue
+               $this->queue( $path );
+               $this->loadFromQueue();
+       }
+
+       /**
+        * Whether a thing has been loaded
+        * @param string $name
+        * @return bool
+        */
+       public function isLoaded( $name ) {
+               return isset( $this->loaded[$name] );
+       }
+
+       /**
+        * @param string $name
+        * @return array
+        */
+       public function getAttribute( $name ) {
+               if ( isset( $this->attributes[$name] ) ) {
+                       return $this->attributes[$name];
+               } else {
+                       return array();
+               }
+       }
+
+       /**
+        * Get information about all things
+        *
+        * @return array
+        */
+       public function getAllThings() {
+               return $this->loaded;
+       }
+
+       /**
+        * Mark a thing as loaded
+        *
+        * @param string $name
+        * @param array $credits
+        */
+       protected function markLoaded( $name, array $credits ) {
+               $this->loaded[$name] = $credits;
+       }
+
+       /**
+        * Register classes with the autoloader
+        *
+        * @param string $dir
+        * @param array $info
+        * @return array
+        */
+       protected function processAutoLoader( $dir, array $info ) {
+               if ( isset( $info['AutoloadClasses'] ) ) {
+                       // Make paths absolute, relative to the JSON file
+                       return array_map( function( $file ) use ( $dir ) {
+                               return "$dir/$file";
+                       }, $info['AutoloadClasses'] );
+               } else {
+                       return array();
+               }
+       }
+
+       /**
+        * @param string $filename absolute path to the JSON file
+        * @param int $mtime modified time of the file
+        * @return array
+        */
+       protected function loadInfoFromFile( $filename, $mtime ) {
+               $key = wfMemcKey( 'registry', md5( $filename ) );
+               $cached = $this->cache->get( $key );
+               if ( isset( $cached['mtime'] ) && $cached['mtime'] === $mtime ) {
+                       return $cached['info'];
+               }
+
+               $contents = file_get_contents( $filename );
+               $json = json_decode( $contents, /* $assoc = */ true );
+               if ( is_array( $json ) ) {
+                       $this->cache->set( $key, array( 'mtime' => $mtime, 'info' => $json ) );
+               } else {
+                       // Don't throw an error here, but don't cache it either.
+                       // @todo log somewhere?
+                       $json = array();
+               }
+
+               return $json;
+       }
+}
diff --git a/includes/registration/Processor.php b/includes/registration/Processor.php
new file mode 100644 (file)
index 0000000..e930fd3
--- /dev/null
@@ -0,0 +1,27 @@
+<?php
+
+/**
+ * Processors read associated arrays and register
+ * whatever is required
+ *
+ * @since 1.25
+ */
+interface Processor {
+
+       /**
+        * Main entry point, processes the information
+        * provided.
+        * Callers should call "callback" after calling
+        * this function.
+        *
+        * @param string $path Absolute path of JSON file
+        * @param array $info
+        * @return array "credits" information to store
+        */
+       public function extractInfo( $path, array $info );
+
+       /**
+        * @return array With 'globals', 'defines', 'callbacks', 'credits' keys.
+        */
+       public function getExtractedInfo();
+}
diff --git a/maintenance/convertExtensionToRegistration.php b/maintenance/convertExtensionToRegistration.php
new file mode 100644 (file)
index 0000000..cbdc1ba
--- /dev/null
@@ -0,0 +1,113 @@
+<?php
+
+require_once __DIR__ . '/Maintenance.php';
+
+class ConvertExtensionToRegistration extends Maintenance {
+
+       protected $custom = array(
+               'MessagesDirs' => 'removeAbsolutePath',
+               'ExtensionMessagesFiles' => 'removeAbsolutePath',
+               'AutoloadClasses' => 'removeAbsolutePath',
+               'ExtensionCredits' => 'handleCredits',
+               'ResourceModules' => 'handleResourceModules',
+               'Hooks' => 'handleHooks',
+               'ExtensionFunctions' => 'handleExtensionFunctions',
+       );
+
+       private $json, $dir;
+
+       public function __construct() {
+               parent::__construct();
+               $this->mDescription = 'Converts extension entry points to the new JSON registration format';
+       }
+
+       public function execute() {
+               require $this->getArg( 0 );
+               // Try not to create any local variables before this line
+               $vars = get_defined_vars();
+               unset( $vars['this'] );
+               $this->dir = dirname( realpath( $this->getArg( 0 ) ) );
+               $this->json = array();
+               $processor = new ReflectionClass( 'ExtensionProcessor' );
+               $settings = $processor->getProperty( 'globalSettings' );
+               $settings->setAccessible( true );
+               $globalSettings = $settings->getValue();
+               foreach ( $vars as $name => $value ) {
+                       $realName = substr( $name, 2 ); // Strip 'wg'
+                       if ( isset( $this->custom[$realName] ) ) {
+                               call_user_func_array( array( $this, $this->custom[$realName] ), array( $realName, $value ) );
+                       } elseif ( in_array( $realName, $globalSettings ) ) {
+                               $this->json[$realName] = $value;
+                       } elseif ( strpos( $name, 'wg' ) === 0 ) {
+                               // Most likely a config setting
+                               $this->json['config'][$realName] = $value;
+                       }
+               }
+
+               $fname = "{$this->dir}/extension.json";
+               $prettyJSON = FormatJson::encode( $this->json, "\t" );
+               file_put_contents( $fname, $prettyJSON . "\n" );
+               $this->output( "Wrote output to $fname.\n" );
+       }
+
+       protected function handleExtensionFunctions( $realName, $value ) {
+               foreach ( $value as $func ) {
+                       if ( $func instanceof Closure ) {
+                               $this->error( "Error: Closures cannot be converted to JSON. Please move your extension function somewhere else.", 1 );
+                       }
+               }
+
+               $this->json[$realName] = $value;
+       }
+
+       private function stripPath( $val, $dir ) {
+               if ( strpos( $val, $dir ) === 0 ) {
+                       // +1 is for the trailing / that won't be in $this->dir
+                       $val = substr( $val, strlen( $dir ) + 1 );
+               }
+
+               return $val;
+       }
+
+       protected function removeAbsolutePath( $realName, $value ) {
+               $out = array();
+               foreach ( $value as $key => $val ) {
+                       $out[$key] = $this->stripPath( $val, $this->dir );
+               }
+               $this->json[$realName] = $out;
+       }
+
+       protected function handleCredits( $realName, $value) {
+               $keys = array_keys( $value );
+               $this->json['type'] = $keys[0];
+               $values = array_values( $value );
+               foreach ( $values[0][0] as $name => $val ) {
+                       if ( $name !== 'path' ) {
+                               $this->json[$name] = $val;
+                       }
+               }
+       }
+
+       public function handleHooks( $realName, $value ) {
+               foreach ( $value as $hookName => $handlers ) {
+                       foreach ( $handlers as $func ) {
+                               if ( $func instanceof Closure ) {
+                                       $this->error( "Error: Closures cannot be converted to JSON. Please move the handler for $hookName somewhere else.", 1 );
+                               }
+                       }
+               }
+               $this->json[$realName] = $value;
+       }
+
+       protected function handleResourceModules( $realName, $value ) {
+               foreach ( $value as $name => $data ) {
+                       if ( isset( $data['localBasePath'] ) ) {
+                               $data['localBasePath'] = $this->stripPath( $data['localBasePath'], $this->dir );
+                       }
+                       $this->json[$realName][$name] = $data;
+               }
+       }
+}
+
+$maintClass = 'ConvertExtensionToRegistration';
+require_once RUN_MAINTENANCE_IF_MAIN;
index e4380a7..6616057 100644 (file)
@@ -68,6 +68,7 @@ if ( file_exists( "$IP/StartProfiler.php" ) ) {
 // Some other requires
 require_once "$IP/includes/Defines.php";
 require_once "$IP/includes/DefaultSettings.php";
+require_once "$IP/includes/GlobalFunctions.php";
 
 # Load composer's autoloader if present
 if ( is_readable( "$IP/vendor/autoload.php" ) ) {
diff --git a/maintenance/validateRegistrationFile.php b/maintenance/validateRegistrationFile.php
new file mode 100644 (file)
index 0000000..e764661
--- /dev/null
@@ -0,0 +1,37 @@
+<?php
+
+require_once __DIR__ . '/Maintenance.php';
+
+class ValidateRegistrationFile extends Maintenance {
+       public function __construct() {
+               parent::__construct();
+               $this->addArg( 'path', 'Path to extension.json/skin.json file.', true );
+       }
+       public function execute() {
+               if ( !class_exists( 'JsonSchema\Uri\UriRetriever' ) ) {
+                       $this->error( 'The JsonSchema library cannot be found, please install it through composer.', 1 );
+               }
+
+               $retriever = new JsonSchema\Uri\UriRetriever();
+               $schema = $retriever->retrieve('file://' . dirname( __DIR__ ) . '/docs/extension.schema.json' );
+               $path = $this->getArg( 0 );
+               $data = json_decode( file_get_contents( $path ) );
+               if ( !is_object( $data ) ) {
+                       $this->error( "$path is not a valid JSON file.", 1 );
+               }
+
+               $validator = new JsonSchema\Validator();
+               $validator->check( $data, $schema );
+               if ( $validator->isValid() ) {
+                       $this->output( "$path validates against the schema!\n" );
+               } else {
+                       foreach ( $validator->getErrors() as $error ) {
+                               $this->output( "[{$error['property']}] {$error['message']}\n" );
+                       }
+                       $this->error( "$path does not validate.", 1 );
+               }
+       }
+}
+
+$maintClass = 'ValidateRegistrationFile';
+require_once RUN_MAINTENANCE_IF_MAIN;
diff --git a/tests/phpunit/includes/registration/ExtensionProcessorTest.php b/tests/phpunit/includes/registration/ExtensionProcessorTest.php
new file mode 100644 (file)
index 0000000..221c258
--- /dev/null
@@ -0,0 +1,135 @@
+<?php
+
+class ExtensionProcessorTest extends MediaWikiTestCase {
+
+       private $dir;
+
+       public function setUp() {
+               parent::setUp();
+               $this->dir = __DIR__ . '/FooBar/extension.json';
+       }
+
+       /**
+        * 'name' is absolutely required
+        *
+        * @var array
+        */
+       static $default = array(
+               'name' => 'FooBar',
+       );
+
+       public static function provideRegisterHooks() {
+               return array(
+                       // No hooks
+                       array(
+                               array(),
+                               self::$default,
+                               array(),
+                       ),
+                       // No current hooks, adding one for "FooBaz"
+                       array(
+                               array(),
+                               array( 'Hooks' => array( 'FooBaz' => 'FooBazCallback' ) ) + self::$default,
+                               array( 'FooBaz' => array( 'FooBazCallback' ) ),
+                       ),
+                       // Hook for "FooBaz", adding another one
+                       array(
+                               array( 'FooBaz' => array( 'PriorCallback' ) ),
+                               array( 'Hooks' => array( 'FooBaz' => 'FooBazCallback' ) ) + self::$default,
+                               array( 'FooBaz' => array( 'PriorCallback', 'FooBazCallback' ) ),
+                       ),
+                       // Hook for "BarBaz", adding one for "FooBaz"
+                       array(
+                               array( 'BarBaz' => array( 'BarBazCallback' ) ),
+                               array( 'Hooks' => array( 'FooBaz' => 'FooBazCallback' ) ) + self::$default,
+                               array(
+                                       'BarBaz' => array( 'BarBazCallback' ),
+                                       'FooBaz' => array( 'FooBazCallback' ),
+                               ),
+                       ),
+               );
+       }
+
+       /**
+        * @covers ExtensionProcessor::extractHooks
+        * @dataProvider provideRegisterHooks
+        */
+       public function testRegisterHooks( $pre, $info, $expected ) {
+               $processor = new MockExtensionProcessor( array( 'wgHooks' => $pre ) );
+               $processor->extractInfo( $this->dir, $info );
+               $extracted = $processor->getExtractedInfo();
+               $this->assertEquals( $expected, $extracted['globals']['wgHooks'] );
+       }
+
+       /**
+        * @covers ExtensionProcessor::extractConfig
+        */
+       public function testExtractConfig() {
+               $processor = new ExtensionProcessor;
+               $info = array(
+                       'config' => array(
+                               'Bar' => 'somevalue',
+                               'Foo' => 10,
+                       ),
+               ) + self::$default;
+               $processor->extractInfo( $this->dir, $info );
+               $extracted = $processor->getExtractedInfo();
+               $this->assertEquals( 'somevalue', $extracted['globals']['wgBar'] );
+               $this->assertEquals( 10, $extracted['globals']['wgFoo'] );
+       }
+
+       public static function provideSetToGlobal() {
+               return array(
+                       array(
+                               array( 'wgAPIModules', 'wgAvailableRights' ),
+                               array(),
+                               array(
+                                       'APIModules' => array( 'foobar' => 'ApiFooBar' ),
+                                       'AvailableRights' => array( 'foobar', 'unfoobar' ),
+                               ),
+                               array(
+                                       'wgAPIModules' => array( 'foobar' => 'ApiFooBar' ),
+                                       'wgAvailableRights' => array( 'foobar', 'unfoobar' ),
+                               ),
+                       ),
+                       array(
+                               array( 'wgAPIModules', 'wgAvailableRights' ),
+                               array(
+                                       'wgAPIModules' => array( 'barbaz' => 'ApiBarBaz' ),
+                                       'wgAvailableRights' => array( 'barbaz' )
+                               ),
+                               array(
+                                       'APIModules' => array( 'foobar' => 'ApiFooBar' ),
+                                       'AvailableRights' => array( 'foobar', 'unfoobar' ),
+                               ),
+                               array(
+                                       'wgAPIModules' => array( 'barbaz' => 'ApiBarBaz', 'foobar' => 'ApiFooBar' ),
+                                       'wgAvailableRights' => array( 'barbaz', 'foobar', 'unfoobar' ),
+                               ),
+                       ),
+                       array(
+                               array( 'wgGroupPermissions' ),
+                               array(
+                                       'wgGroupPermissions' => array( 'sysop' => array( 'delete' ) ),
+                               ),
+                               array(
+                                       'GroupPermissions' => array( 'sysop' => array( 'undelete' ), 'user' => array( 'edit' ) ),
+                               ),
+                               array(
+                                       'wgGroupPermissions' => array( 'sysop' => array( 'delete', 'undelete' ), 'user' => array( 'edit' ) ),
+                               )
+                       )
+               );
+       }
+}
+
+
+/**
+ * Allow overriding the default value of $this->globals
+ * so we can test merging
+ */
+class MockExtensionProcessor extends ExtensionProcessor {
+       public function __construct( $globals = array() ) {
+               $this->globals = $globals + $this->globals;
+       }
+}