@oml/language 0.14.17 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,7 @@ import {
6
6
  isAnnotation,
7
7
  isConceptInstance,
8
8
  isDescription,
9
+ isDescriptionBundle,
9
10
  isDescriptionBox,
10
11
  isImport,
11
12
  isOntology,
@@ -20,9 +21,20 @@ import { getOntologyModelIndex } from './oml-index.js';
20
21
  import { serializeOntology } from './oml-serializer.js';
21
22
  import {
22
23
  collectOntologyMembers,
24
+ expandRefTextToIri,
23
25
  findOntologyMemberByName,
26
+ getLocalNameForNamespace,
27
+ getRefText,
24
28
  getIriForNode,
29
+ isSameIriTarget,
30
+ localNameFromRefText,
31
+ normalizeIri,
25
32
  normalizeNamespace,
33
+ parseIriParts,
34
+ pickImportPrefix,
35
+ resolveImportNamespaceRaw,
36
+ resolveImportPrefix,
37
+ withNamespaceSeparator,
26
38
  } from './oml-utils.js';
27
39
 
28
40
  type ConnectionLike = {
@@ -32,56 +44,122 @@ type ConnectionLike = {
32
44
  };
33
45
  };
34
46
 
35
- const OmlEditRequestType = new RequestType<OmlEditRequest, OmlEditResponse, void>('oml/update');
47
+ const OmlUpdateRequestType = new RequestType<OmlUpdateRequest, OmlUpdateResponse, void>('oml/update');
36
48
  const RDF_TYPE_IRI = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type';
37
49
  const OML_HAS_SOURCE_IRI = 'http://opencaesar.io/oml#hasSource';
38
50
  const OML_HAS_TARGET_IRI = 'http://opencaesar.io/oml#hasTarget';
39
51
  const XSD_STRING_IRI = 'http://www.w3.org/2001/XMLSchema#string';
40
52
 
41
- type OmlEditOperation =
42
- | { kind: 'createInstance'; ontologyIri: string; instanceName: string }
43
- | { kind: 'createRelationInstance'; ontologyIri: string; instanceName: string }
44
- | { kind: 'createInstanceRef'; ontologyIri: string; instanceIri: string; typeIri?: string }
45
- | { kind: 'createRelationInstanceRef'; ontologyIri: string; instanceIri: string; typeIri?: string }
46
- | { kind: 'addAssertion'; ontologyIri: string; subjectIri: string; predicateIri: string; object: unknown }
47
- | { kind: 'updateAssertion'; ontologyIri: string; subjectIri: string; predicateIri: string; object: unknown }
48
- | { kind: 'removeAssertion'; ontologyIri: string; subjectIri: string; predicateIri: string; object?: unknown }
53
+ type OmlUpdateOperation =
54
+ | { kind: 'createInstance'; descriptionIri: string; instanceName: string }
55
+ | { kind: 'createRelationInstance'; descriptionIri: string; instanceName: string }
56
+ | { kind: 'createInstanceRef'; descriptionIri: string; instanceIri: string; typeIri?: string }
57
+ | { kind: 'createRelationInstanceRef'; descriptionIri: string; instanceIri: string; typeIri?: string }
58
+ | { kind: 'addAssertion'; descriptionIri: string; subjectIri: string; predicateIri: string; object: unknown }
59
+ | { kind: 'updateAssertion'; descriptionIri: string; subjectIri: string; predicateIri: string; object: unknown }
60
+ | { kind: 'removeAssertion'; descriptionIri: string; subjectIri: string; predicateIri: string; object?: unknown }
49
61
  | { kind: 'addAnnotation'; ontologyIri: string; subjectIri: string; predicateIri: string; object: unknown }
50
62
  | { kind: 'updateAnnotation'; ontologyIri: string; subjectIri: string; predicateIri: string; object: unknown }
51
63
  | { kind: 'removeAnnotation'; ontologyIri: string; subjectIri: string; predicateIri: string; object?: unknown }
52
64
  | { kind: 'deleteMemberCascade'; ontologyIri: string; memberIri: string }
53
- | { kind: 'deleteMemberRef'; ontologyIri: string; memberIri: string; typeIri?: string };
65
+ | { kind: 'deleteMemberRef'; ontologyIri: string; memberIri: string; typeIri?: string }
66
+ | { kind: 'deleteOntology'; ontologyIri: string }
67
+ | { kind: 'addImport'; importingIri: string; importedIri: string }
68
+ | { kind: 'removeImport'; importingIri: string; importedIri: string }
69
+ | {
70
+ kind: 'createOntology';
71
+ ontologyKind: 'vocabulary' | 'description' | 'vocabulary bundle' | 'description bundle';
72
+ ontologyNamespace: string;
73
+ ontologyPrefix: string;
74
+ targetFolder?: string;
75
+ };
54
76
 
55
- export type OmlEditRequest = {
56
- operations: OmlEditOperation[];
77
+ export type OmlUpdateRequest = {
78
+ operations: OmlUpdateOperation[];
57
79
  referencingUri?: string;
58
80
  };
59
81
 
60
- export type OmlEditError = {
82
+ export type OmlUpdateError = {
61
83
  operationIndex: number;
62
84
  message: string;
63
85
  };
64
86
 
65
- export type OmlEditResponse = {
87
+ export type OmlUpdateResponse = {
66
88
  edit?: WorkspaceEdit;
67
- errors?: OmlEditError[];
89
+ errors?: OmlUpdateError[];
68
90
  };
69
91
 
70
92
  export async function applyOmlUpdate(
71
93
  shared: any,
72
- params: OmlEditRequest,
94
+ params: OmlUpdateRequest,
73
95
  logError?: (message: string) => void,
74
- ): Promise<OmlEditResponse> {
96
+ workspaceUri?: string,
97
+ preview?: boolean,
98
+ ): Promise<OmlUpdateResponse> {
75
99
  const operations = Array.isArray(params?.operations) ? params.operations : [];
76
- const referencingUri = typeof params?.referencingUri === 'string'
77
- ? params.referencingUri.trim()
78
- : undefined;
79
100
  const contexts = new Map<string, OntologyContext>();
80
101
  const changedOntologyIris = new Set<string>();
102
+ const createdOntologyByIri = new Map<string, CreatedOntology>();
103
+ const deletedOntologyModelUriByIri = new Map<string, string>();
81
104
  for (let i = 0; i < operations.length; i += 1) {
82
105
  const operation = operations[i];
83
106
  try {
84
- const context = await resolveOntologyContext(shared, operation.ontologyIri, contexts, referencingUri);
107
+ if (operation.kind === 'createOntology') {
108
+ const created = await createOntologyOperation(shared, operation, workspaceUri);
109
+ contexts.set(created.ontologyIri, {
110
+ ontologyIri: created.ontologyIri,
111
+ modelUri: created.modelUri,
112
+ ontology: created.ontology,
113
+ document: undefined
114
+ });
115
+ createdOntologyByIri.set(created.ontologyIri, created);
116
+ changedOntologyIris.add(created.ontologyIri);
117
+ continue;
118
+ }
119
+ if (operation.kind === 'deleteOntology') {
120
+ const normalizedOntologyIri = normalizeOntologyIriKey(operation.ontologyIri);
121
+ if (createdOntologyByIri.has(normalizedOntologyIri)) {
122
+ createdOntologyByIri.delete(normalizedOntologyIri);
123
+ contexts.delete(normalizedOntologyIri);
124
+ changedOntologyIris.delete(normalizedOntologyIri);
125
+ continue;
126
+ }
127
+ const existingContext = contexts.get(normalizedOntologyIri);
128
+ if (existingContext) {
129
+ deletedOntologyModelUriByIri.set(existingContext.ontologyIri, existingContext.modelUri);
130
+ contexts.delete(existingContext.ontologyIri);
131
+ changedOntologyIris.delete(existingContext.ontologyIri);
132
+ continue;
133
+ }
134
+ const modelUri = resolveModelUriFromLoadedDocuments(shared, normalizedOntologyIri);
135
+ if (!modelUri) {
136
+ // Idempotent delete: if ontology is already absent, treat as successful no-op.
137
+ continue;
138
+ }
139
+ deletedOntologyModelUriByIri.set(normalizedOntologyIri, modelUri);
140
+ contexts.delete(normalizedOntologyIri);
141
+ changedOntologyIris.delete(normalizedOntologyIri);
142
+ continue;
143
+ }
144
+ if (operation.kind === 'addImport') {
145
+ const importing = await resolveOntologyContext(shared, operation.importingIri, contexts, workspaceUri);
146
+ const imported = await resolveOntologyContext(shared, operation.importedIri, contexts, workspaceUri);
147
+ const changed = await addImport(shared, importing.ontology, imported.ontology);
148
+ if (changed) {
149
+ changedOntologyIris.add(importing.ontologyIri);
150
+ }
151
+ continue;
152
+ }
153
+ if (operation.kind === 'removeImport') {
154
+ const importing = await resolveOntologyContext(shared, operation.importingIri, contexts, workspaceUri);
155
+ const imported = await resolveOntologyContext(shared, operation.importedIri, contexts, workspaceUri);
156
+ const changed = removeImport(importing.ontology, imported.ontology);
157
+ if (changed) {
158
+ changedOntologyIris.add(importing.ontologyIri);
159
+ }
160
+ continue;
161
+ }
162
+ const context = await resolveOntologyContext(shared, getOperationTargetOntologyIri(operation), contexts, workspaceUri);
85
163
  const changed = await executeOperation(shared, context, operation, contexts);
86
164
  for (const ontologyIri of changed) {
87
165
  changedOntologyIris.add(ontologyIri);
@@ -92,7 +170,7 @@ export async function applyOmlUpdate(
92
170
  const details = error instanceof Error && error.stack
93
171
  ? `${message}\n${error.stack}`
94
172
  : message;
95
- logError?.(`[oml] OmlEditRequest failed at operation ${i} (${operation.kind}): ${details}`);
173
+ logError?.(`[oml] OmlUpdateRequest failed at operation ${i} (${operation.kind}): ${details}`);
96
174
  return {
97
175
  errors: [{
98
176
  operationIndex: i,
@@ -101,13 +179,40 @@ export async function applyOmlUpdate(
101
179
  };
102
180
  }
103
181
  }
104
- const edit = buildWorkspaceEdit(contexts, changedOntologyIris);
182
+ const edit = buildWorkspaceEdit(contexts, changedOntologyIris, createdOntologyByIri, deletedOntologyModelUriByIri);
183
+ if (preview) {
184
+ const existingContexts = new Map([...contexts.entries()].filter(([iri]) => !createdOntologyByIri.has(iri)));
185
+ await restoreContextsFromDocuments(shared, existingContexts);
186
+ }
105
187
  return edit ? { edit } : {};
106
188
  }
107
189
 
108
- export function registerOmlEditRequests(connection: ConnectionLike, shared: any): void {
109
- connection.onRequest(OmlEditRequestType, async (params: OmlEditRequest): Promise<OmlEditResponse> => {
110
- return await applyOmlUpdate(shared, params, (message) => connection.console?.error(message));
190
+ function resolveModelUriFromLoadedDocuments(shared: any, ontologyIri: string): string | undefined {
191
+ const normalized = normalizeOntologyIriKey(ontologyIri);
192
+ const documentsService: any = shared.workspace.LangiumDocuments;
193
+ const all = documentsService?.all ?? [];
194
+ const iterable: any[] = Array.isArray(all)
195
+ ? all
196
+ : (typeof all?.toArray === 'function' ? all.toArray() : Array.from(all as Iterable<any>));
197
+ for (const document of iterable) {
198
+ const root = document?.parseResult?.value;
199
+ if (!root || !isOntology(root)) {
200
+ continue;
201
+ }
202
+ const namespace = normalizeNamespace(String((root as any).namespace ?? ''));
203
+ if (!namespace) {
204
+ continue;
205
+ }
206
+ if (normalizeOntologyIriKey(namespace) === normalized) {
207
+ return String(document.uri);
208
+ }
209
+ }
210
+ return undefined;
211
+ }
212
+
213
+ export function registerOmlUpdateRequests(connection: ConnectionLike, shared: any): void {
214
+ connection.onRequest(OmlUpdateRequestType, async (params: OmlUpdateRequest): Promise<OmlUpdateResponse> => {
215
+ return await applyOmlUpdate(shared, params, (message) => connection.console?.error(message), params.referencingUri);
111
216
  });
112
217
  }
113
218
 
@@ -118,13 +223,34 @@ type OntologyContext = {
118
223
  document: any;
119
224
  };
120
225
 
226
+ function getOperationTargetOntologyIri(operation: OmlUpdateOperation): string {
227
+ switch (operation.kind) {
228
+ case 'createInstance':
229
+ case 'createRelationInstance':
230
+ case 'createInstanceRef':
231
+ case 'createRelationInstanceRef':
232
+ case 'addAssertion':
233
+ case 'updateAssertion':
234
+ case 'removeAssertion':
235
+ return operation.descriptionIri;
236
+ case 'addAnnotation':
237
+ case 'updateAnnotation':
238
+ case 'removeAnnotation':
239
+ case 'deleteMemberCascade':
240
+ case 'deleteMemberRef':
241
+ return operation.ontologyIri;
242
+ default:
243
+ throw new Error(`Unsupported operation target resolution for '${(operation as any).kind}'.`);
244
+ }
245
+ }
246
+
121
247
  async function resolveOntologyContext(
122
248
  shared: any,
123
249
  ontologyIri: string,
124
250
  cache: Map<string, OntologyContext>,
125
251
  referencingUri?: string
126
252
  ): Promise<OntologyContext> {
127
- const normalizedOntologyIri = normalizeIri(ontologyIri);
253
+ const normalizedOntologyIri = normalizeOntologyIriKey(ontologyIri);
128
254
  const cached = cache.get(normalizedOntologyIri);
129
255
  if (cached) {
130
256
  return cached;
@@ -157,7 +283,7 @@ async function resolveOntologyContext(
157
283
  async function executeOperation(
158
284
  shared: any,
159
285
  context: OntologyContext,
160
- operation: OmlEditOperation,
286
+ operation: OmlUpdateOperation,
161
287
  contexts: Map<string, OntologyContext>
162
288
  ): Promise<ReadonlySet<string>> {
163
289
  switch (operation.kind) {
@@ -1064,93 +1190,6 @@ function toRefText(ontology: any, iri: string): string {
1064
1190
  return `<${normalizedIri}>`;
1065
1191
  }
1066
1192
 
1067
- function getLocalNameForNamespace(iri: string, rawNamespace: string | undefined): string | undefined {
1068
- const namespace = normalizeNamespace(String(rawNamespace ?? ''));
1069
- if (!namespace) {
1070
- return undefined;
1071
- }
1072
- const hashPrefix = `${namespace}#`;
1073
- if (iri.startsWith(hashPrefix) && iri.length > hashPrefix.length) {
1074
- return iri.slice(hashPrefix.length);
1075
- }
1076
- const slashPrefix = `${namespace}/`;
1077
- if (iri.startsWith(slashPrefix) && iri.length > slashPrefix.length) {
1078
- return iri.slice(slashPrefix.length);
1079
- }
1080
- return undefined;
1081
- }
1082
-
1083
- function resolveImportPrefix(imp: any): string | undefined {
1084
- const explicitPrefix = typeof imp?.prefix === 'string' ? imp.prefix.trim() : '';
1085
- if (explicitPrefix) {
1086
- return explicitPrefix;
1087
- }
1088
- const resolvedPrefix = typeof imp?.imported?.ref?.prefix === 'string' ? imp.imported.ref.prefix.trim() : '';
1089
- return resolvedPrefix || undefined;
1090
- }
1091
-
1092
- function resolveImportNamespaceRaw(imp: any): string | undefined {
1093
- const resolvedNamespace = typeof imp?.imported?.ref?.namespace === 'string' ? imp.imported.ref.namespace : '';
1094
- if (resolvedNamespace.trim().length > 0) {
1095
- return resolvedNamespace.trim();
1096
- }
1097
- const refText = typeof imp?.imported?.$refText === 'string' ? imp.imported.$refText.trim() : '';
1098
- return refText || undefined;
1099
- }
1100
-
1101
- function expandRefTextToIri(ontology: any, refText: string): string | undefined {
1102
- const trimmed = String(refText ?? '').trim();
1103
- if (!trimmed) {
1104
- return undefined;
1105
- }
1106
- if (trimmed === 'a') {
1107
- return RDF_TYPE_IRI;
1108
- }
1109
- if (trimmed.startsWith('<') && trimmed.endsWith('>')) {
1110
- return normalizeIri(trimmed);
1111
- }
1112
- if (trimmed.includes('://')) {
1113
- return normalizeIri(trimmed);
1114
- }
1115
- const localInCurrentOntology = expandLocalRefText(ontology?.namespace, trimmed);
1116
- if (localInCurrentOntology) {
1117
- return localInCurrentOntology;
1118
- }
1119
- const separatorIndex = trimmed.indexOf(':');
1120
- if (separatorIndex <= 0) {
1121
- return undefined;
1122
- }
1123
- const prefix = trimmed.slice(0, separatorIndex);
1124
- const local = trimmed.slice(separatorIndex + 1);
1125
- if (!local) {
1126
- return undefined;
1127
- }
1128
- if (typeof ontology?.prefix === 'string' && ontology.prefix.trim() === prefix) {
1129
- return expandLocalRefText(ontology.namespace, local);
1130
- }
1131
- const imports = Array.isArray(ontology?.ownedImports) ? ontology.ownedImports : [];
1132
- for (const imp of imports) {
1133
- if (resolveImportPrefix(imp) !== prefix) {
1134
- continue;
1135
- }
1136
- return expandLocalRefText(resolveImportNamespaceRaw(imp), local);
1137
- }
1138
- return undefined;
1139
- }
1140
-
1141
- function expandLocalRefText(rawNamespace: string | undefined, localName: string): string | undefined {
1142
- const namespaceText = String(rawNamespace ?? '').trim();
1143
- const local = String(localName ?? '').trim();
1144
- if (!namespaceText || !local) {
1145
- return undefined;
1146
- }
1147
- const namespace = normalizeIri(namespaceText);
1148
- if (namespace.endsWith('#') || namespace.endsWith('/')) {
1149
- return `${namespace}${local}`;
1150
- }
1151
- return `${namespace}#${local}`;
1152
- }
1153
-
1154
1193
  async function ensureReferenceImport(shared: any, ontology: any, iri: string): Promise<void> {
1155
1194
  const target = parseIriParts(iri);
1156
1195
  if (!target) {
@@ -1161,23 +1200,28 @@ async function ensureReferenceImport(shared: any, ontology: any, iri: string): P
1161
1200
  return;
1162
1201
  }
1163
1202
  const imports = Array.isArray(ontology?.ownedImports) ? ontology.ownedImports : [];
1164
- const hasImport = imports.some((imp: any) => {
1165
- const importedNamespace = normalizeNamespace(String(resolveImportNamespaceRaw(imp) ?? ''));
1166
- return importedNamespace === target.namespace;
1167
- });
1168
- if (hasImport) {
1169
- return;
1170
- }
1171
1203
  const usedPrefixes = new Set<string>();
1172
1204
  const rootPrefix = typeof ontology?.prefix === 'string' ? ontology.prefix.trim() : '';
1173
1205
  if (rootPrefix) {
1174
1206
  usedPrefixes.add(rootPrefix);
1175
1207
  }
1176
1208
  for (const imp of imports) {
1177
- const prefix = resolveImportPrefix(imp);
1178
- if (prefix) {
1179
- usedPrefixes.add(prefix);
1209
+ const p = resolveImportPrefix(imp);
1210
+ if (p) {
1211
+ usedPrefixes.add(p);
1212
+ }
1213
+ }
1214
+ const existingImport = imports.find((imp: any) => {
1215
+ const importedNamespace = normalizeNamespace(String(resolveImportNamespaceRaw(imp) ?? ''));
1216
+ return importedNamespace === target.namespace;
1217
+ });
1218
+ if (existingImport) {
1219
+ // If the existing import has no prefix (e.g., a bundle `includes` added without one),
1220
+ // patch it now so that the reference can use the abbreviated form.
1221
+ if (!resolveImportPrefix(existingImport)) {
1222
+ existingImport.prefix = pickImportPrefix(target.namespace, usedPrefixes);
1180
1223
  }
1224
+ return;
1181
1225
  }
1182
1226
  const prefix = pickImportPrefix(target.namespace, usedPrefixes);
1183
1227
  const importedRef = `<${target.namespace}${target.separator}>`;
@@ -1191,15 +1235,97 @@ async function ensureReferenceImport(shared: any, ontology: any, iri: string): P
1191
1235
  pushAstArrayChild(ontology, 'ownedImports', importStatement);
1192
1236
  }
1193
1237
 
1194
- async function resolveImportKind(shared: any, importing: any, importedNamespace: string): Promise<'extends' | 'includes' | 'uses'> {
1195
- const imported = await findOntologyByNamespace(shared, importedNamespace);
1238
+ async function addImport(shared: any, importingOntology: any, importedOntology: any): Promise<boolean> {
1239
+ const importingNamespace = normalizeNamespace(String(importingOntology?.namespace ?? ''));
1240
+ const importedNamespaceRaw = String(importedOntology?.namespace ?? '').trim();
1241
+ const importedNamespace = normalizeNamespace(importedNamespaceRaw);
1242
+ if (!importingNamespace || !importedNamespace) {
1243
+ throw new Error('addImport requires both importing and imported ontologies to have valid namespaces.');
1244
+ }
1245
+ if (importingNamespace === importedNamespace) {
1246
+ return false;
1247
+ }
1248
+
1249
+ const imports = Array.isArray(importingOntology?.ownedImports) ? importingOntology.ownedImports : [];
1250
+ const hasImport = imports.some((imp: any) => {
1251
+ const imported = normalizeNamespace(String(resolveImportNamespaceRaw(imp) ?? ''));
1252
+ return imported === importedNamespace;
1253
+ });
1254
+ if (hasImport) {
1255
+ return false;
1256
+ }
1257
+
1258
+ const separator: '#' | '/' = importedNamespaceRaw.endsWith('/') ? '/' : '#';
1259
+ const importedRef = `<${importedNamespace}${separator}>`;
1260
+ const importKind = await resolveImportKind(shared, importingOntology, importedNamespace, importedOntology);
1261
+ const usedPrefixes = new Set<string>();
1262
+ const rootPrefix = typeof importingOntology?.prefix === 'string' ? importingOntology.prefix.trim() : '';
1263
+ if (rootPrefix) {
1264
+ usedPrefixes.add(rootPrefix);
1265
+ }
1266
+ for (const imp of imports) {
1267
+ const p = resolveImportPrefix(imp);
1268
+ if (p) {
1269
+ usedPrefixes.add(p);
1270
+ }
1271
+ }
1272
+ // `includes` never gets a prefix — if an annotation later references a term from this
1273
+ // vocabulary, ensureReferenceImport will patch the prefix onto this import at that point.
1274
+ let prefix: string | undefined;
1275
+ if (importKind !== 'includes') {
1276
+ const candidatePrefix = typeof importedOntology?.prefix === 'string' ? importedOntology.prefix.trim() : '';
1277
+ prefix = candidatePrefix && !usedPrefixes.has(candidatePrefix)
1278
+ ? candidatePrefix
1279
+ : pickImportPrefix(importedNamespace, usedPrefixes);
1280
+ }
1281
+ const importStatement: any = {
1282
+ $type: 'Import',
1283
+ kind: importKind,
1284
+ ...(prefix ? { prefix } : {}),
1285
+ imported: { $refText: importedRef }
1286
+ };
1287
+ pushAstArrayChild(importingOntology, 'ownedImports', importStatement);
1288
+ return true;
1289
+ }
1290
+
1291
+ function removeImport(importingOntology: any, importedOntology: any): boolean {
1292
+ const importedNamespace = normalizeNamespace(String(importedOntology?.namespace ?? ''));
1293
+ if (!importedNamespace) {
1294
+ throw new Error('removeImport requires imported ontology to have a valid namespace.');
1295
+ }
1296
+ const imports = Array.isArray(importingOntology?.ownedImports) ? importingOntology.ownedImports : [];
1297
+ if (imports.length === 0) {
1298
+ return false;
1299
+ }
1300
+ const kept = imports.filter((imp: any) => {
1301
+ const namespace = normalizeNamespace(String(resolveImportNamespaceRaw(imp) ?? ''));
1302
+ return namespace !== importedNamespace;
1303
+ });
1304
+ if (kept.length === imports.length) {
1305
+ return false;
1306
+ }
1307
+ importingOntology.ownedImports = kept;
1308
+ return true;
1309
+ }
1310
+
1311
+ async function resolveImportKind(shared: any, importing: any, importedNamespace: string, importedOntology?: any): Promise<'extends' | 'includes' | 'uses'> {
1312
+ const imported = importedOntology ?? await findOntologyByNamespace(shared, importedNamespace);
1196
1313
  if (imported) {
1197
1314
  if (importing.$type === imported.$type) {
1198
1315
  return 'extends';
1199
1316
  }
1317
+ if (isDescriptionBundle(importing) && isDescription(imported)) {
1318
+ return 'includes';
1319
+ }
1200
1320
  if (isVocabulary(importing) && isDescription(imported)) {
1201
1321
  return 'uses';
1202
1322
  }
1323
+ if (isDescriptionBundle(importing) && isVocabularyBundle(imported)) {
1324
+ return 'uses';
1325
+ }
1326
+ if (isDescriptionBundle(importing) && isVocabulary(imported)) {
1327
+ return 'uses';
1328
+ }
1203
1329
  if (isDescriptionBox(importing) && isVocabulary(imported)) {
1204
1330
  return 'uses';
1205
1331
  }
@@ -1232,43 +1358,6 @@ async function findOntologyByNamespace(shared: any, namespace: string): Promise<
1232
1358
  return ontology && isOntology(ontology) ? ontology : undefined;
1233
1359
  }
1234
1360
 
1235
- function parseIriParts(iri: string): { namespace: string; fragment: string; separator: '#' | '/' } | undefined {
1236
- const normalized = normalizeIri(iri);
1237
- if (!normalized) {
1238
- return undefined;
1239
- }
1240
- const hashIndex = normalized.lastIndexOf('#');
1241
- if (hashIndex > 0 && hashIndex < normalized.length - 1) {
1242
- return {
1243
- namespace: normalizeNamespace(normalized.slice(0, hashIndex)),
1244
- fragment: normalized.slice(hashIndex + 1),
1245
- separator: '#',
1246
- };
1247
- }
1248
- const slashIndex = normalized.lastIndexOf('/');
1249
- if (slashIndex > 0 && slashIndex < normalized.length - 1) {
1250
- return {
1251
- namespace: normalizeNamespace(normalized.slice(0, slashIndex)),
1252
- fragment: normalized.slice(slashIndex + 1),
1253
- separator: '/',
1254
- };
1255
- }
1256
- return undefined;
1257
- }
1258
-
1259
- function getRefText(ref: any): string | undefined {
1260
- if (typeof ref === 'string') {
1261
- return ref;
1262
- }
1263
- if (typeof ref?.$refText === 'string') {
1264
- return ref.$refText;
1265
- }
1266
- if (typeof ref?.$refNode?.text === 'string') {
1267
- return ref.$refNode.text;
1268
- }
1269
- return undefined;
1270
- }
1271
-
1272
1361
  function matchesRefTextTarget(
1273
1362
  ontology: any,
1274
1363
  statement: any,
@@ -1328,59 +1417,6 @@ function matchesRefTextFragment(statement: any, fragment: string): boolean {
1328
1417
  return !!local && local === fragment;
1329
1418
  }
1330
1419
 
1331
- function localNameFromRefText(refText: string): string | undefined {
1332
- const trimmed = refText.trim().replace(/^<|>$/g, '');
1333
- if (!trimmed) {
1334
- return undefined;
1335
- }
1336
- const colonIndex = trimmed.indexOf(':');
1337
- if (colonIndex > 0 && colonIndex < trimmed.length - 1) {
1338
- return trimmed.slice(colonIndex + 1);
1339
- }
1340
- const hashIndex = trimmed.lastIndexOf('#');
1341
- if (hashIndex >= 0 && hashIndex < trimmed.length - 1) {
1342
- return trimmed.slice(hashIndex + 1);
1343
- }
1344
- const slashIndex = trimmed.lastIndexOf('/');
1345
- if (slashIndex >= 0 && slashIndex < trimmed.length - 1) {
1346
- return trimmed.slice(slashIndex + 1);
1347
- }
1348
- return trimmed;
1349
- }
1350
-
1351
- function isSameIriTarget(left: string, right: string): boolean {
1352
- const leftNorm = normalizeIri(left);
1353
- const rightNorm = normalizeIri(right);
1354
- if (leftNorm === rightNorm) {
1355
- return true;
1356
- }
1357
- const leftParts = parseIriParts(leftNorm);
1358
- const rightParts = parseIriParts(rightNorm);
1359
- if (!leftParts || !rightParts) {
1360
- return false;
1361
- }
1362
- return leftParts.namespace === rightParts.namespace
1363
- && leftParts.fragment === rightParts.fragment;
1364
- }
1365
-
1366
- function pickImportPrefix(namespace: string, usedPrefixes: ReadonlySet<string>): string {
1367
- const normalizedNamespace = normalizeNamespace(namespace);
1368
- const pieces = normalizedNamespace.split('/').filter((piece) => piece.length > 0);
1369
- const tail = (pieces[pieces.length - 1] ?? normalizedNamespace)
1370
- .replace(/[^A-Za-z0-9_]/g, '')
1371
- .toLowerCase();
1372
- const base = /^[A-Za-z_]/.test(tail) ? tail : `ns${tail}`;
1373
- const candidateBase = (base || 'ns').slice(0, 32);
1374
- if (!usedPrefixes.has(candidateBase)) {
1375
- return candidateBase;
1376
- }
1377
- let suffix = 1;
1378
- while (usedPrefixes.has(`${candidateBase}${suffix}`)) {
1379
- suffix += 1;
1380
- }
1381
- return `${candidateBase}${suffix}`;
1382
- }
1383
-
1384
1420
  function asLiteral(ontology: any, value: unknown): any {
1385
1421
  if (isTypedQuotedLiteralTransport(value)) {
1386
1422
  const literalValue = String(value.value ?? '');
@@ -1541,8 +1577,8 @@ function isIriLike(value: unknown): boolean {
1541
1577
  return trimmed.includes('://');
1542
1578
  }
1543
1579
 
1544
- function normalizeIri(value: string): string {
1545
- return String(value ?? '').trim().replace(/^<|>$/g, '');
1580
+ function normalizeOntologyIriKey(value: string): string {
1581
+ return normalizeNamespace(normalizeIri(value));
1546
1582
  }
1547
1583
 
1548
1584
  function normalizePropertyAssertionGroup(subject: any, target: any, duplicates: any[]): void {
@@ -1662,17 +1698,26 @@ function isTargetPredicate(predicateIri: string): boolean {
1662
1698
 
1663
1699
  function buildWorkspaceEdit(
1664
1700
  contexts: Map<string, OntologyContext>,
1665
- changedOntologyIris: ReadonlySet<string>
1701
+ changedOntologyIris: ReadonlySet<string>,
1702
+ createdOntologyByIri: ReadonlyMap<string, CreatedOntology>,
1703
+ deletedOntologyModelUriByIri: ReadonlyMap<string, string>
1666
1704
  ): WorkspaceEdit | undefined {
1667
- if (changedOntologyIris.size === 0) {
1705
+ if (changedOntologyIris.size === 0 && createdOntologyByIri.size === 0 && deletedOntologyModelUriByIri.size === 0) {
1668
1706
  return undefined;
1669
1707
  }
1670
1708
  const changes: NonNullable<WorkspaceEdit['changes']> = {};
1709
+ const deletedModelUris = new Set<string>([...deletedOntologyModelUriByIri.values()]);
1671
1710
  for (const ontologyIri of [...changedOntologyIris].sort()) {
1711
+ if (createdOntologyByIri.has(ontologyIri)) {
1712
+ continue;
1713
+ }
1672
1714
  const context = contexts.get(ontologyIri);
1673
1715
  if (!context) {
1674
1716
  continue;
1675
1717
  }
1718
+ if (deletedModelUris.has(context.modelUri)) {
1719
+ continue;
1720
+ }
1676
1721
  const serialized = serializeOntology(context.ontology);
1677
1722
  const textDocument = context.document?.textDocument;
1678
1723
  if (!textDocument) {
@@ -1687,15 +1732,206 @@ function buildWorkspaceEdit(
1687
1732
  newText: serialized
1688
1733
  }];
1689
1734
  }
1690
- return Object.keys(changes).length > 0 ? { changes } : undefined;
1735
+ for (const [createdIri, created] of [...createdOntologyByIri.entries()].sort(([left], [right]) => left.localeCompare(right))) {
1736
+ const createdContext = contexts.get(createdIri);
1737
+ const content = createdContext ? serializeOntology(createdContext.ontology) : created.content;
1738
+ changes[created.modelUri] = [{
1739
+ range: {
1740
+ start: { line: 0, character: 0 },
1741
+ end: { line: 0, character: 0 }
1742
+ },
1743
+ newText: content
1744
+ }];
1745
+ }
1746
+ const documentChanges = [...deletedModelUris].sort().map((uri) => ({ kind: 'delete' as const, uri }));
1747
+ if (Object.keys(changes).length === 0 && documentChanges.length === 0) {
1748
+ return undefined;
1749
+ }
1750
+ if (documentChanges.length === 0) {
1751
+ return { changes };
1752
+ }
1753
+ if (Object.keys(changes).length === 0) {
1754
+ return { documentChanges };
1755
+ }
1756
+ return { changes, documentChanges };
1757
+ }
1758
+
1759
+ type CreatedOntology = {
1760
+ ontologyIri: string;
1761
+ modelUri: string;
1762
+ content: string;
1763
+ ontology: any;
1764
+ };
1765
+
1766
+ function detectOmlSrcDocument(shared: any): string | undefined {
1767
+ const documentsService: any = shared.workspace.LangiumDocuments;
1768
+ const all = documentsService?.all ?? [];
1769
+ const iterable: any[] = Array.isArray(all)
1770
+ ? all
1771
+ : (typeof all?.toArray === 'function' ? all.toArray() : Array.from(all as Iterable<any>));
1772
+ for (const document of iterable) {
1773
+ const uri = String(document?.uri ?? '');
1774
+ if (uri.includes('/oml/')) {
1775
+ return uri;
1776
+ }
1777
+ }
1778
+ return undefined;
1779
+ }
1780
+
1781
+ async function createOntologyOperation(
1782
+ shared: any,
1783
+ operation: Extract<OmlUpdateOperation, { kind: 'createOntology' }>,
1784
+ workspaceUri?: string
1785
+ ): Promise<CreatedOntology> {
1786
+ const namespaceCore = normalizeNamespace(operation.ontologyNamespace);
1787
+ if (!namespaceCore) {
1788
+ throw new Error('createOntology requires a non-empty ontologyNamespace.');
1789
+ }
1790
+ const ontologyPrefix = operation.ontologyPrefix.trim();
1791
+ if (!ontologyPrefix) {
1792
+ throw new Error('createOntology requires a non-empty ontologyPrefix.');
1793
+ }
1794
+ const namespaceWithSeparator = withNamespaceSeparator(operation.ontologyNamespace);
1795
+ const index = getOntologyModelIndex(shared);
1796
+ const referencingUri = operation.targetFolder ? undefined : detectOmlSrcDocument(shared) ?? workspaceUri;
1797
+ const existingModelUri = index.resolveModelUri(namespaceCore, referencingUri);
1798
+ if (existingModelUri) {
1799
+ throw new Error(`Ontology '${namespaceWithSeparator}' already exists at '${existingModelUri}'.`);
1800
+ }
1801
+
1802
+ const modelUri = deriveNewOntologyModelUri(namespaceCore, referencingUri, operation.targetFolder, workspaceUri);
1803
+ const ontologyIri = normalizeOntologyIriKey(namespaceWithSeparator);
1804
+ const ontology = createOntologyAst(operation.ontologyKind, namespaceWithSeparator, ontologyPrefix);
1805
+ const content = serializeOntology(ontology as any);
1806
+ return {
1807
+ ontologyIri,
1808
+ modelUri,
1809
+ content,
1810
+ ontology
1811
+ };
1812
+ }
1813
+
1814
+ function deriveNewOntologyModelUri(namespaceCore: string, referencingUri?: string, targetFolder?: string, workspaceUri?: string): string {
1815
+ let namespaceUrl: URL;
1816
+ try {
1817
+ namespaceUrl = new URL(namespaceCore.includes('://') ? namespaceCore : `http://${namespaceCore}`);
1818
+ } catch {
1819
+ throw new Error(`Unable to derive ontology file path from namespace '${namespaceCore}'.`);
1820
+ }
1821
+ const nsHost = namespaceUrl.host;
1822
+ const nsPath = (namespaceUrl.pathname ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
1823
+ const relative = [nsHost, ...nsPath.split('/').filter(Boolean)].join('/');
1824
+ if (!relative) {
1825
+ throw new Error(`Unable to derive ontology file path from namespace '${namespaceCore}'.`);
1826
+ }
1827
+
1828
+ if (targetFolder) {
1829
+ const normalizedFolder = targetFolder.trim().replace(/\/+$/, '');
1830
+ if (!normalizedFolder) {
1831
+ throw new Error('createOntology targetFolder must not be empty.');
1832
+ }
1833
+ let folderPath = normalizedFolder;
1834
+ if (!normalizedFolder.startsWith('/') && workspaceUri) {
1835
+ const workspacePath = URI.parse(workspaceUri).path ?? '';
1836
+ folderPath = `${workspacePath.replace(/\/+$/, '')}/${normalizedFolder}`;
1837
+ }
1838
+ return URI.from({
1839
+ scheme: 'file',
1840
+ authority: '',
1841
+ path: `${folderPath}/${relative}.oml`,
1842
+ query: undefined,
1843
+ fragment: undefined
1844
+ }).toString();
1845
+ }
1846
+
1847
+ if (!referencingUri) {
1848
+ throw new Error('createOntology requires either targetFolder or referencingUri to derive output path.');
1849
+ }
1850
+ let reference: URI;
1851
+ try {
1852
+ reference = URI.parse(referencingUri);
1853
+ } catch {
1854
+ throw new Error(`Invalid referencingUri '${referencingUri}'.`);
1855
+ }
1856
+ const refPath = reference.path ?? '';
1857
+ const marker = '/oml/';
1858
+ const markerIndex = refPath.lastIndexOf(marker);
1859
+ if (markerIndex < 0) {
1860
+ throw new Error(`Unable to derive ontology path from referencingUri '${referencingUri}': missing '/oml/' segment.`);
1861
+ }
1862
+ const basePath = refPath.slice(0, markerIndex + marker.length);
1863
+ const targetPath = `${basePath}${relative}.oml`;
1864
+ return URI.from({
1865
+ scheme: reference.scheme,
1866
+ authority: reference.authority,
1867
+ path: targetPath,
1868
+ query: reference.query,
1869
+ fragment: undefined
1870
+ }).toString();
1871
+ }
1872
+
1873
+ function createOntologyAst(
1874
+ ontologyKind: 'vocabulary' | 'description' | 'vocabulary bundle' | 'description bundle',
1875
+ namespace: string,
1876
+ prefix: string
1877
+ ): any {
1878
+ switch (ontologyKind) {
1879
+ case 'vocabulary':
1880
+ return {
1881
+ $type: 'Vocabulary',
1882
+ namespace,
1883
+ prefix,
1884
+ ownedAnnotations: [],
1885
+ ownedImports: [],
1886
+ ownedStatements: []
1887
+ };
1888
+ case 'description':
1889
+ return {
1890
+ $type: 'Description',
1891
+ namespace,
1892
+ prefix,
1893
+ ownedAnnotations: [],
1894
+ ownedImports: [],
1895
+ ownedStatements: []
1896
+ };
1897
+ case 'vocabulary bundle':
1898
+ return {
1899
+ $type: 'VocabularyBundle',
1900
+ namespace,
1901
+ prefix,
1902
+ ownedAnnotations: [],
1903
+ ownedImports: []
1904
+ };
1905
+ case 'description bundle':
1906
+ return {
1907
+ $type: 'DescriptionBundle',
1908
+ namespace,
1909
+ prefix,
1910
+ ownedAnnotations: [],
1911
+ ownedImports: []
1912
+ };
1913
+ default:
1914
+ throw new Error(`Unsupported ontology kind '${(ontologyKind as any)}'.`);
1915
+ }
1691
1916
  }
1692
1917
 
1693
1918
  async function restoreContextsFromDocuments(shared: any, contexts: Map<string, OntologyContext>): Promise<void> {
1694
1919
  const uris = [...new Set([...contexts.values()].map((context) => URI.parse(context.modelUri)))];
1920
+ const langiumDocuments: any = shared.workspace.LangiumDocuments;
1921
+ // Langium skips re-parsing when the on-disk text matches the CST text stored in the
1922
+ // current parseResult. Preview modifies the AST in-place without changing the file, so
1923
+ // the texts are still equal and the optimisation would keep the modified AST. Clearing
1924
+ // $cstNode forces oldText to be undefined, guaranteeing a fresh parse from disk.
1925
+ for (const context of contexts.values()) {
1926
+ const doc = langiumDocuments.getDocument(URI.parse(context.modelUri));
1927
+ const root = doc?.parseResult?.value as any;
1928
+ if (root) {
1929
+ root.$cstNode = undefined;
1930
+ }
1931
+ }
1695
1932
  if (uris.length > 0) {
1696
1933
  await shared.workspace.DocumentBuilder.update(uris, []);
1697
1934
  }
1698
- const langiumDocuments: any = shared.workspace.LangiumDocuments;
1699
1935
  for (const context of contexts.values()) {
1700
1936
  const refreshed = langiumDocuments.getDocument(URI.parse(context.modelUri))
1701
1937
  ?? await langiumDocuments.getOrCreateDocument(URI.parse(context.modelUri));