diff --git a/www/config.html b/www/config.html
index f976702a..58f0c3cf 100644
--- a/www/config.html
+++ b/www/config.html
@@ -461,6 +461,33 @@
return {type: 'string'};
};
+ const parseYamlValue = (raw) => {
+ const trimmed = raw.trim();
+ if (!trimmed) return {ok: false};
+ if (/^\$\{[^}{]+\}$/.test(trimmed)) return {ok: false, dynamic: true};
+ if (trimmed.startsWith('|') || trimmed.startsWith('>')) return {ok: false, block: true};
+ if (window.jsyaml && window.jsyaml.load) {
+ try {
+ return {ok: true, value: window.jsyaml.load(trimmed)};
+ } catch (e) {
+ // nothing
+ }
+ }
+ if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) {
+ const inner = trimmed.slice(1, -1);
+ return {ok: true, value: inner.replace(/\\"/g, '"').replace(/\\\\/g, '\\')};
+ }
+ if (trimmed.startsWith('\'') && trimmed.endsWith('\'') && trimmed.length >= 2) {
+ const inner = trimmed.slice(1, -1);
+ return {ok: true, value: inner.replace(/''/g, '\'')};
+ }
+ if (trimmed === 'true' || trimmed === 'false') return {ok: true, value: trimmed === 'true'};
+ if (trimmed === 'null' || trimmed === '~') return {ok: true, value: null};
+ if (isIntLike(trimmed)) return {ok: true, value: parseInt(trimmed, 10)};
+ if (isNumberLike(trimmed)) return {ok: true, value: Number(trimmed)};
+ return {ok: true, value: trimmed};
+ };
+
const lintYamlModel = (model, schemaTools) => {
const markers = [];
const markedLines = new Set();
@@ -587,6 +614,133 @@
});
};
+ const valueEquals = (a, b) => {
+ if (a === b) return true;
+ if (typeof a !== typeof b) return false;
+ if (a && b && typeof a === 'object') {
+ const aIsArray = Array.isArray(a);
+ const bIsArray = Array.isArray(b);
+ if (aIsArray !== bIsArray) return false;
+ if (aIsArray) {
+ if (a.length !== b.length) return false;
+ for (let i = 0; i < a.length; i++) {
+ if (!valueEquals(a[i], b[i])) return false;
+ }
+ return true;
+ }
+ const aKeys = Object.keys(a);
+ const bKeys = Object.keys(b);
+ if (aKeys.length !== bKeys.length) return false;
+ for (const key of aKeys) {
+ if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
+ if (!valueEquals(a[key], b[key])) return false;
+ }
+ return true;
+ }
+ return false;
+ };
+
+ const schemaAllowsTypeLoose = (schema, actualType) => {
+ if (!schemaTools || !schema) return true;
+ const types = schemaTools.getSchemaTypes(schema);
+ if (types.size === 0) return true;
+ if (actualType === 'integer' && types.has('number')) return true;
+ return types.has(actualType);
+ };
+
+ const collectConstraintSchemas = (schema, actualType) => {
+ if (!schemaTools || !schema) return [];
+ schema = schemaTools.resolveRef(schema);
+ if (!schema) return [];
+ if (Array.isArray(schema.anyOf)) {
+ const res = [];
+ for (const alt of schema.anyOf) res.push(...collectConstraintSchemas(alt, actualType));
+ return res;
+ }
+ if (Array.isArray(schema.oneOf)) {
+ const res = [];
+ for (const alt of schema.oneOf) res.push(...collectConstraintSchemas(alt, actualType));
+ return res;
+ }
+ if (!schemaAllowsTypeLoose(schema, actualType)) return [];
+ return [schema];
+ };
+
+ const getSchemaEnumValues = (schema) => {
+ const values = [];
+ if (Array.isArray(schema.enum)) values.push(...schema.enum);
+ if (Object.prototype.hasOwnProperty.call(schema, 'const')) values.push(schema.const);
+ return values;
+ };
+
+ const checkValueConstraints = (schema, actualType, rawValue, lineNumber, startColumn, endColumn, keyName) => {
+ if (!schemaTools || !schema) return;
+ if (actualType === 'dynamic') return;
+ const parsed = parseYamlValue(rawValue);
+ if (!parsed.ok) return;
+
+ const candidates = collectConstraintSchemas(schema, actualType);
+ if (candidates.length === 0) return;
+
+ const hasConstraints = candidates.some((s) => (
+ (Array.isArray(s.enum) || Object.prototype.hasOwnProperty.call(s, 'const')) ||
+ (actualType === 'string' && typeof s.pattern === 'string')
+ ));
+ if (!hasConstraints) return;
+
+ const hasUnconstrained = candidates.some((s) => (
+ !(Array.isArray(s.enum) || Object.prototype.hasOwnProperty.call(s, 'const')) &&
+ !(actualType === 'string' && typeof s.pattern === 'string')
+ ));
+ if (hasUnconstrained) return;
+
+ const value = parsed.value;
+ const matchesAny = candidates.some((s) => {
+ const enums = getSchemaEnumValues(s);
+ if (enums.length > 0 && !enums.some((v) => valueEquals(v, value))) return false;
+ if (actualType === 'string' && typeof s.pattern === 'string') {
+ try {
+ const re = new RegExp(s.pattern);
+ if (!re.test(String(value))) return false;
+ } catch (e) {
+ return true;
+ }
+ }
+ return true;
+ });
+ if (matchesAny) return;
+
+ const enumValues = [];
+ const patterns = [];
+ for (const s of candidates) {
+ enumValues.push(...getSchemaEnumValues(s));
+ if (actualType === 'string' && typeof s.pattern === 'string') patterns.push(s.pattern);
+ }
+ const enumLabel = unique(enumValues).map((v) => toYamlScalar(v)).join(', ');
+ const patternLabel = unique(patterns).join(' | ');
+
+ let message;
+ const label = keyName ? `"${keyName}"` : 'value';
+ if (enumValues.length && patterns.length) {
+ message = `Value for ${label} must be one of: ${enumLabel}; or match pattern: ${patternLabel}`;
+ } else if (enumValues.length) {
+ message = `Value for ${label} must be one of: ${enumLabel}`;
+ } else if (patterns.length) {
+ message = `Value for ${label} must match pattern: ${patternLabel}`;
+ } else {
+ return;
+ }
+
+ pushMarker({
+ severity: monaco.MarkerSeverity.Error,
+ message,
+ startLineNumber: lineNumber,
+ startColumn,
+ endLineNumber: lineNumber,
+ endColumn: Math.max(startColumn + 1, endColumn),
+ });
+ };
+
for (let lineNumber = 1; lineNumber <= lineCount; lineNumber++) {
let line = model.getLineContent(lineNumber);
if (!line.trim()) continue;
@@ -702,12 +856,14 @@
const actual = classifyYamlScalar(valueText).type;
const valueStartColumn = kv.valueStartIndex + 1 + (kv.after.length - kv.after.trimStart().length);
checkValueType(propSchema, actual, lineNumber, valueStartColumn, line.length + 1, kv.key);
+ checkValueConstraints(propSchema, actual, valueText, lineNumber, valueStartColumn, line.length + 1, kv.key);
}
continue;
}
const scalar = classifyYamlScalar(listItem.rest).type;
checkValueType(itemSchema, scalar, lineNumber, listItem.afterDashIndex + 1, line.length + 1, null);
+ checkValueConstraints(itemSchema, scalar, listItem.rest, lineNumber, listItem.afterDashIndex + 1, line.length + 1, null);
continue;
}
@@ -774,6 +930,7 @@
const actual = classifyYamlScalar(kv.after).type;
const valueStartColumn = kv.valueStartIndex + 1 + (kv.after.length - kv.after.trimStart().length);
checkValueType(propSchema, actual, lineNumber, valueStartColumn, line.length + 1, kv.key);
+ checkValueConstraints(propSchema, actual, kv.after, lineNumber, valueStartColumn, line.length + 1, kv.key);
}
return markers;
@@ -910,11 +1067,12 @@
const props = getObjectProperties(contextSchema);
const suggestions = Object.keys(props).map((key) => {
const s = resolveRef(props[key]);
- const wantsBlock = s && (s.type === 'object' || s.type === 'array' || s.properties);
+ const wantsArray = s && s.type === 'array';
+ const wantsBlock = s && (s.type === 'object' || wantsArray || s.properties);
const indent = listItem ? ' '.repeat(listItem.contentIndent) : ' '.repeat(countIndent(lineNoComment));
const innerIndent = indent + ' ';
- const insertText = wantsBlock ? `${key}:\n${innerIndent}` : `${key}: `;
+ const insertText = wantsArray ? `${key}:\n${indent}` : (wantsBlock ? `${key}:\n${innerIndent}` : `${key}: `);
const hasValueSuggestions = !wantsBlock && getValueSuggestions(s).length > 0;
return {