Create Tools and Profiles¶
XSMP Modeler can be extended with custom tools and profiles. A contribution is
loaded by XSMP Modeler, made available in xsmp.project, and then applied only
to projects that enable it.
A complete demonstration repository is available at ydaveluy/xsmp-tool-demo. It shows a VS Code extension package that contributes a generator and a validator.
Choose a Contribution Kind¶
Use a tool when the behavior is optional and task-oriented:
- export or generate extra files
- add checks for a specific workflow
- scaffold optional folders or configuration files
Use a profile when the behavior represents a project convention or target platform:
- select one project layout or runtime ecosystem
- add profile-specific generation
- add validation rules that should apply to the whole project
Projects can enable several tools, but at most one profile:
project 'mission-demo'
source 'smdl'
profile 'xsmp-sdk'
tool 'smp'
tool 'adoc'
Package Layout¶
An external contribution is usually packaged as a VS Code extension that depends on XSMP Modeler:
xsmp-tool-demo/
package.json
src/
demo.xsmptool
contributor.ts
generator.ts
validator.ts
The extension manifest exposes the XSMP contribution through contributes.xsmp:
{
"extensionDependencies": [
"ydaveluy.xsmp-modeler"
],
"contributes": {
"xsmp": [
{
"descriptor": "./lib/demo.xsmptool",
"handler": "./lib/contributor.js",
"apiVersion": "^2.0.0"
}
]
},
"dependencies": {
"@xsmp/core": "^2.0.0",
"langium": "4.2.4"
}
}
The descriptor is a small XSMP contribution document. Tools use .xsmptool:
/**
* Demo XSMP tool.
*/
tool 'demo-tool'
Profiles use .xsmpprofile:
/**
* Demo XSMP profile.
*/
profile 'demo-profile'
The identifier declared by the descriptor is the identifier used in
xsmp.project:
tool 'demo-tool'
Register the Contribution¶
The handler must export registerContribution. XSMP Modeler calls it once the
descriptor has been loaded.
import { DemoGenerator } from './generator.js';
import { registerDemoValidation } from './validator.js';
import type { XsmpContributionRegistrationApi } from '@xsmp/core/contributions';
export function registerContribution(api: XsmpContributionRegistrationApi): void {
api.setWizardMetadata({
label: 'Demo Tool',
description: 'Demonstrates a custom generator and validator.',
});
api.addGenerator(services => new DemoGenerator(services));
api.addValidation('xsmpcat', registerDemoValidation);
}
The same handler shape is used by tools and profiles. The descriptor determines
whether the contribution is a tool or a profile.
Add a Generator¶
Generators implement XsmpGenerator. Each generator has a stable id, unique
within its contribution. For each project generation, XSMP Modeler calls
prepare once on every active generator, validates all output definitions, then
calls generate for each valid project document.
import { type AstNode } from 'langium';
import * as ast from '@xsmp/core/ast';
import type {
GeneratedFiles, GenerationContext, GenerationPreparationContext, XsmpGenerator,
} from '@xsmp/core/generator';
export class DemoGenerator implements XsmpGenerator {
readonly id = 'summary';
prepare(context: GenerationPreparationContext): GeneratedFiles {
return context.createFiles({
generated: {
directory: 'demo-gen',
writeMode: 'if-changed',
cleanup: 'directory',
},
user: {
directory: 'src',
writeMode: 'if-missing',
},
});
}
generate(node: AstNode, { files, acceptTask }: GenerationContext): void {
if (ast.isCatalogue(node)) {
acceptTask(() => files.write(
'generated', `${node.name}-summary.md`, `# ${node.name}\n`,
));
acceptTask(() => files.write(
'user', `${node.name}-notes.md`, () => this.initialNotes(node),
));
}
}
private initialNotes(catalogue: ast.Catalogue): string {
return `# Notes for ${catalogue.name}\n`;
}
}
The preparation context provides projectUri, cancelToken and createFiles.
The execution context provides the same project and cancellation token, the
prepared files, and acceptTask. Capture that context in tasks rather than
storing the current session in a field on the generator. Submit asynchronous
work through acceptTask; XSMP Modeler limits concurrency and drains all
accepted tasks before closing the session. Tasks cannot submit more work after
traversal has finished.
files.write(outputName, relativePath, content) accepts either a string or a
function returning string | Promise<string>. In if-missing mode, an existing
file skips the function and formatter entirely. When creation is needed, an
exclusive write preserves any file that appeared during rendering. In
if-changed mode, the content is produced and the file is written only when its
bytes differ. The function is called at most once per write; its errors fail
generation. Content functions should only produce content, since they can be
skipped. Pass a function for asynchronous rendering, not an already started
promise.
Paths are relative to the named output directory, itself relative to the
project. files.resolvePath(outputName, relativePath) resolves an input path
without disk access, for example to inspect an existing generation-gap header.
Output names separate the generator's rendering from a profile's directory
configuration. Conflicting writes to the same normalized destination are errors,
including writes from different generators or output names. Shared files such
as Python package initializers must be scheduled once per session.
Each output independently specifies its cleanup policy:
cleanup |
Behavior after complete successful generation |
|---|---|
none (default) |
Preserve obsolete files. Required for if-missing. |
tracked |
Remove obsolete files recorded as owned by this generator/output; preserve unknown files in the same directory. |
directory |
Synchronize a directory reserved for generated files, removing every unexpected file, including manual additions. |
Use if-changed with directory for src-gen, include-gen, adoc-gen and
smdl-gen. Use if-changed with tracked for generated Python modules in mixed
folders. Use if-missing for generation-gap files and Python user stubs. Mixed
outputs can share a directory; a directory purged in its entirety cannot contain
protected user outputs. Cleanup preserves the union of outputs from all active
generators. An unchanged file remains an expected output even when no write
occurs. Failure, cancellation or skipped invalid documents prevents cleanup.
Files already written are not rolled back.
Output directories differing only in case cannot use incompatible cleanup
policies, so the same configuration stays safe on case-insensitive volumes.
Declared output roots are kept in place, including nested roots. Empty
subdirectories locked by another process on Windows are left for a later
generation to prune. Failure to remove an obsolete file is still reported.
Selective cleanup uses the project's .xsmp/generated-files.json inventory,
which records generator/output ownership and paths, not file contents. Keep this
local metadata across sessions to remove obsolete generated modules after a
restart; exclude .xsmp/ from version control. Missing or corrupt inventory
preserves unknown files. Changing an output directory, removing a generator or
changing ownership does not implicitly authorize deletion in the old location.
Outputs may specify an optional formatter. The common writer caches only
SHA-256 hashes of raw and formatted bytes, the output path, and the formatter's
identity. It skips formatting only when the new raw hash, formatter identity and
actual disk hash match the preceding result. It always checks current disk
bytes, so an external edit or deletion is repaired at the next generation.
The bounded metadata cache is shared across project generations; eviction or a
restart only requires recomputation. It retains no generated text, ASTs or
content functions. Python, ADoc and SMP currently use direct byte comparison
without a formatter. Opening a workspace with startup generation disabled does
not write or delete outputs.
Model inputs, formatter configuration and tool version must remain stable during
a generation; changes are re-evaluated on the next pass.
Migrating Existing Generators¶
The extension API is 2.0.0. After migrating, set apiVersion to ^2.0.0
in the contribution manifest; extensions requiring API 1 are not loaded.
Add a stable generator id. Replace clean with
prepare(context): GeneratedFiles, returning context.createFiles with named
output definitions. Change generate(node, projectUri, acceptTask) to
generate(node, context), and replace direct writes with
context.files.write(outputName, relativePath, content). Use lazy content for
files generated only when missing. Remove generator-side deletion and formatting
caches: the common writer owns disk comparison, optional formatting, ownership
and cleanup. Keep rendering caches tied to their model documents as before.
C++ subclasses now return text from generatePackageHeader,
generatePackageSource, generateDynamicPackageSource, generateHeader,
generateSource, generateHeaderGen and generateSourceGen. These methods no
longer write files. Remove overrides of the former generateFile and format
methods; output writing and formatting belong to the common writer.
Add Validation¶
Validation is registered per XSMP language. The category passed to the registrar is the active contribution id; XSMP Modeler runs that category only for projects that enable the tool or profile.
import type { ValidationAcceptor, ValidationChecks } from 'langium';
import * as ast from '@xsmp/core/ast-partial';
import type { XsmpcatServices } from '@xsmp/core';
export function registerDemoValidation(services: XsmpcatServices, category: string): void {
const registry = services.validation.ValidationRegistry;
const validator = new DemoCatalogueValidator();
const checks: ValidationChecks<ast.XsmpAstType> = {
Catalogue: validator.checkCatalogue,
NamedElement: validator.checkNamedElement,
};
registry.register(checks, validator, category);
}
class DemoCatalogueValidator {
checkCatalogue(catalogue: ast.Catalogue, accept: ValidationAcceptor): void {
if (!catalogue.elements.some(ast.isNamespace)) {
accept('warning', 'Catalogue should contain at least one namespace.', {
node: catalogue,
keyword: 'catalogue',
});
}
}
checkNamedElement(element: ast.NamedElement, accept: ValidationAcceptor): void {
if (element.name?.startsWith('tmp')) {
accept('warning', "Avoid public XSMP names prefixed with 'tmp'.", {
node: element,
property: 'name',
});
}
}
}
Available language ids are:
xsmpprojectxsmpcatxsmpcfgxsmpasbxsmplnkxsmpsed
Add Wizard Scaffolding¶
Tools and profiles can also add project wizard content:
import * as path from 'node:path';
import type { XsmpContributionScaffoldContext } from '@xsmp/core/contributions';
export async function scaffoldDemoTool(context: XsmpContributionScaffoldContext): Promise<void> {
await context.writeFile(
path.join(context.projectDir, 'README.demo-tool.md'),
'This project enables the demo tool.\n',
);
}
Register it from the handler:
api.setScaffolder(scaffoldDemoTool);
The scaffolder can create files, ensure directories, read prompt values, and add project dependencies requested by the contribution.
Include Built-in Model Files¶
If the contribution ships reusable XSMP model files, add them through the
manifest builtins list:
{
"contributes": {
"xsmp": [
{
"descriptor": "./lib/demo-profile.xsmpprofile",
"handler": "./lib/contributor.js",
"apiVersion": "^2.0.0",
"builtins": [
"./lib/builtins"
]
}
]
}
}
Built-in model files are visible only to projects that enable the contribution. They are not inherited through project dependencies.
Development Checklist¶
- Add
@xsmp/coreandlangiumdependencies. - Create a
.xsmptoolor.xsmpprofiledescriptor. - Add a
contributes.xsmpmanifest entry inpackage.json. - Export
registerContributionfrom the handler. - Register generators, validations, scaffolding or built-in model files.
- Compile the handler and copy descriptors into the packaged extension.
- Package the extension with
vsce package --no-dependencies. - Install the VSIX next to XSMP Modeler and enable the contribution in
xsmp.project.