Modules externes

En complément d'OpenLibs()/OpenIOLib()/etc. (bibliothèques intégrées au moteur, voir démarrage.md) et de l'exposition ad hoc d'objets .NET (voir interop-dotnet.md), YScript a un système de modules (namespace YScript.Modules) pour packager un ensemble de fonctions C# comme une bibliothèque scriptable cohérente. Un module externe pousse cette idée un cran plus loin : c'est le même mécanisme, mais packagé dans une assembly .NET séparée de YScript.dll, chargée dynamiquement à l'exécution via require("nom") — sans que votre application hôte (console, éditeur, service embarquant le moteur) ait besoin d'une référence de compilation vers ce module. C'est la façon d'étendre YScript avec vos propres fonctions C# sans toucher au moteur lui-même, et de distribuer cette extension indépendamment (y compris via ypack, voir plus bas).

Les modules externes zip, json et http, distribués avec YScript, illustrent ce mécanisme dans la pratique — chacun a sa propre documentation utilisateur (voir §5).

1. Structure d'un projet de module

Un module est un projet netstandard2.0 classique (comme l'assembly du moteur elle-même, pour rester consommable depuis .NET Framework et .NET récent), avec une seule référence — le moteur :

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <Reference Include="YScript" HintPath="chemin\vers\YScript.dll" Private="false" />
  </ItemGroup>

</Project>

La classe du module hérite de YScript.Modules.BaseScriptModule et porte l'attribut [Module], qui définit son nom d'enregistrement (celui utilisé côté script dans require("nom")) ; chaque méthode publique int Xxx(IScript script) taguée [Function("nom")] devient une fonction exposée dans la table du module :

using YScript.Modules;

namespace MyCompany.Modules.Greetings
{
    [Module("greetings", IsGlobal = true)]
    public class GreetingsModule : BaseScriptModule
    {
        [Function("hello")]
        public int Hello(IScript script)
        {
            script.PushString($"Bonjour, {script.CheckString(1)} !");
            return 1;
        }
    }
}
local greetings = require("greetings")
print(greetings.hello("monde"))   --> Bonjour, monde !

Rien d'autre n'est requis côté module pour l'API de manipulation de script elle-même : elle est strictement identique à celle des bibliothèques internes du moteur (namespace YScript.Libraries) — mêmes conventions de pile (IScript, script.CheckString, script.PushString, etc.), mêmes extensions (ScriptAuxiliary, ScriptExtensions). Un module externe n'est donc pas un mécanisme à part : c'est la même bibliothèque scriptable que celles fournies par le moteur, seulement compilée et distribuée en dehors de YScript.dll.

Messages et localisation

Comme pour le cœur du moteur, tout message destiné à l'utilisateur ou au script (script.Error(...), script.TypeError(...), avertissements) doit venir d'un dictionnaire de ressources .resx/SR*, jamais d'une chaîne littérale inline en C# : un dossier Locales/ à la racine du projet du module, un ou plusieurs .resx associés à une classe générée (ResXFileCodeGenerator), des clés nommées Err_XxxYyy dont les valeurs sont en français, la classe SR générée gardée internal. Dans le code, SR.Err_XxxYyy (avec paramètres de formatage) plutôt qu'une chaîne en dur.

2. Chargement dynamique côté script (require)

Le chargement d'un module externe passe entièrement par la bibliothèque package déjà présente dans le moteur — un module externe n'a besoin d'aucun mécanisme propre. require("json") par exemple :

  1. Recherche du fichier .dll via package.dotnetpath (configurable par la variable d'environnement YSCRIPT_DOTNET_PATH), qui suit le même format que package.path : entrées séparées par ;, ? remplacé par le nom passé à require(...).
  2. Chargement de l'assembly trouvée.
  3. Résolution du type module : parmi les types publics non abstraits de l'assembly dérivant de BaseScriptModule, celui dont le nom résolu (attribut [Module("...")]) correspond exactement au nom demandé.
  4. Instanciation et enregistrement : le type est instancié et OpenModule(script) est appelé — exactement comme le ferait script.Require<T>() pour un module statiquement référencé.

⚠️ Piège principal : la résolution du fichier .dll et la résolution du type module à l'intérieur sont deux étapes indépendantes. Le type est retrouvé par réflexion via [Module("nom")] (le nom de la classe C# n'a aucune importance) ; mais le fichier .dll lui-même doit être physiquement trouvé par substitution littérale du ? dans le gabarit package.dotnetpath, avec la casse exacte de l'argument passé à require(...). Recommandation : nommez l'assembly compilé du module (donc le nom du projet .csproj) exactement comme le nom du module utilisé dans require(...), en minuscules (ex. projet json.csproj → json.dll, gabarit ?.dll par défaut, fonctionne sans configuration supplémentaire sur toutes les plateformes) — Windows/NTFS tolère une casse différente entre le fichier et l'argument de require, mais Linux/macOS non.

3. Chargement côté hôte

Deux façons d'utiliser un module dans une application hôte :

Pour tester un module en développement sans le déployer, pointez YSCRIPT_DOTNET_PATH vers la sortie de build locale plutôt que vers un dossier de déploiement, par exemple en variable d'environnement :

D:/dev/monmodule/bin/Debug/netstandard2.0/?.dll;;

Le ;; final conserve les chemins par défaut de package.dotnetpath en plus du chemin de dev. Sous Visual Studio (F5), la même variable se définit dans les propriétés de lancement (launchSettings.json) du projet consommateur (l'exécutable ou l'application qui héberge le moteur). Dans les deux cas, require("json") déclenche exactement le même pipeline package qu'en environnement de production — aucun code de test/chargement ad hoc n'est nécessaire.

4. Empaqueter le module avec ypack

Un module externe destiné à être publié sur la galerie a, à sa racine (à côté du .csproj), un manifeste ypack.json :

{
  "name": "mymodule",
  "version": "1.0.0",
  "description": "Description du module",
  "author": "Votre nom",
  "engines": { "yscript": ">=0.3.0" },
  "main": "MyCompany.Modules.MyModule.dll",
  "dependencies": {},
  "provides": { "module": true, "command": false }
}

ypack pack <dossier> <dest.zip> s'exécute contre le dossier de sortie de build (typiquement bin/Release/netstandard2.0/), pas contre la racine du projet. Tout fichier qui doit se retrouver dans le paquet publié — ypack.json, documentation/, et tout autre fichier non compilé consommé par le module — doit donc être copié vers ce dossier de sortie à chaque build :

<ItemGroup>
  <None Update="ypack.json">
    <CopyToOutputDirectory>Always</CopyToOutputDirectory>
  </None>
  <None Update="documentation\**\*">
    <CopyToOutputDirectory>Always</CopyToOutputDirectory>
  </None>
</ItemGroup>

Sans ypack.json copié, ypack pack traite le dossier comme un simple bundle sans manifeste plutôt que comme un paquet publiable ; sans documentation/ copié, l'archive produite n'a pas de documentation utilisateur alors que le dossier source du module, lui, en a une.

5. Documenter votre module pour ses utilisateurs

Cette page-ci documente comment écrire un module — pas comment en utiliser un existant. Un module externe a sa propre documentation utilisateur (destinée à l'auteur de script qui consomme require("votremodule")), distincte de cette doc moteur : un dossier documentation/ à la racine du projet du module, avec au minimum un index.md (présentation, liste des fonctions exposées, exemples d'usage, dans l'esprit du manuel de référence Lua) :

mymodule/
  mymodule.csproj
  MyModule.cs
  ypack.json
  documentation/
    index.md          -- point d'entrée, obligatoire

index.md suit le même plan que cette documentation-ci pour chaque fonction exposée : signature, description, valeur de retour, comportement en cas d'erreur, exemple ```lua court quand la fonction a une subtilité — avec, en tête de page, une table de vue d'ensemble (fonction / description) qui résume toute l'API du module.