Skip to content

Commit 7e38b4f

Browse files
committed
Add enum and pattern constraint checks to YAML linter
Enhances the YAML linter to validate scalar values against enum and pattern constraints defined in the schema. Adds utility functions for value comparison, schema constraint collection, and applies these checks during linting to provide more precise error messages for invalid values.
1 parent 0fd2217 commit 7e38b4f

File tree

1 file changed

+160
-2
lines changed

1 file changed

+160
-2
lines changed

www/config.html

Lines changed: 160 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,33 @@
461461
return {type: 'string'};
462462
};
463463

464+
const parseYamlValue = (raw) => {
465+
const trimmed = raw.trim();
466+
if (!trimmed) return {ok: false};
467+
if (/^\$\{[^}{]+\}$/.test(trimmed)) return {ok: false, dynamic: true};
468+
if (trimmed.startsWith('|') || trimmed.startsWith('>')) return {ok: false, block: true};
469+
if (window.jsyaml && window.jsyaml.load) {
470+
try {
471+
return {ok: true, value: window.jsyaml.load(trimmed)};
472+
} catch (e) {
473+
// nothing
474+
}
475+
}
476+
if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) {
477+
const inner = trimmed.slice(1, -1);
478+
return {ok: true, value: inner.replace(/\\"/g, '"').replace(/\\\\/g, '\\')};
479+
}
480+
if (trimmed.startsWith('\'') && trimmed.endsWith('\'') && trimmed.length >= 2) {
481+
const inner = trimmed.slice(1, -1);
482+
return {ok: true, value: inner.replace(/''/g, '\'')};
483+
}
484+
if (trimmed === 'true' || trimmed === 'false') return {ok: true, value: trimmed === 'true'};
485+
if (trimmed === 'null' || trimmed === '~') return {ok: true, value: null};
486+
if (isIntLike(trimmed)) return {ok: true, value: parseInt(trimmed, 10)};
487+
if (isNumberLike(trimmed)) return {ok: true, value: Number(trimmed)};
488+
return {ok: true, value: trimmed};
489+
};
490+
464491
const lintYamlModel = (model, schemaTools) => {
465492
const markers = [];
466493
const markedLines = new Set();
@@ -587,6 +614,133 @@
587614
});
588615
};
589616

617+
const valueEquals = (a, b) => {
618+
if (a === b) return true;
619+
if (typeof a !== typeof b) return false;
620+
if (a && b && typeof a === 'object') {
621+
const aIsArray = Array.isArray(a);
622+
const bIsArray = Array.isArray(b);
623+
if (aIsArray !== bIsArray) return false;
624+
if (aIsArray) {
625+
if (a.length !== b.length) return false;
626+
for (let i = 0; i < a.length; i++) {
627+
if (!valueEquals(a[i], b[i])) return false;
628+
}
629+
return true;
630+
}
631+
const aKeys = Object.keys(a);
632+
const bKeys = Object.keys(b);
633+
if (aKeys.length !== bKeys.length) return false;
634+
for (const key of aKeys) {
635+
if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
636+
if (!valueEquals(a[key], b[key])) return false;
637+
}
638+
return true;
639+
}
640+
return false;
641+
};
642+
643+
const schemaAllowsTypeLoose = (schema, actualType) => {
644+
if (!schemaTools || !schema) return true;
645+
const types = schemaTools.getSchemaTypes(schema);
646+
if (types.size === 0) return true;
647+
if (actualType === 'integer' && types.has('number')) return true;
648+
return types.has(actualType);
649+
};
650+
651+
const collectConstraintSchemas = (schema, actualType) => {
652+
if (!schemaTools || !schema) return [];
653+
schema = schemaTools.resolveRef(schema);
654+
if (!schema) return [];
655+
if (Array.isArray(schema.anyOf)) {
656+
const res = [];
657+
for (const alt of schema.anyOf) res.push(...collectConstraintSchemas(alt, actualType));
658+
return res;
659+
}
660+
if (Array.isArray(schema.oneOf)) {
661+
const res = [];
662+
for (const alt of schema.oneOf) res.push(...collectConstraintSchemas(alt, actualType));
663+
return res;
664+
}
665+
if (!schemaAllowsTypeLoose(schema, actualType)) return [];
666+
return [schema];
667+
};
668+
669+
const getSchemaEnumValues = (schema) => {
670+
const values = [];
671+
if (Array.isArray(schema.enum)) values.push(...schema.enum);
672+
if (Object.prototype.hasOwnProperty.call(schema, 'const')) values.push(schema.const);
673+
return values;
674+
};
675+
676+
const checkValueConstraints = (schema, actualType, rawValue, lineNumber, startColumn, endColumn, keyName) => {
677+
if (!schemaTools || !schema) return;
678+
if (actualType === 'dynamic') return;
679+
const parsed = parseYamlValue(rawValue);
680+
if (!parsed.ok) return;
681+
682+
const candidates = collectConstraintSchemas(schema, actualType);
683+
if (candidates.length === 0) return;
684+
685+
const hasConstraints = candidates.some((s) => (
686+
(Array.isArray(s.enum) || Object.prototype.hasOwnProperty.call(s, 'const')) ||
687+
(actualType === 'string' && typeof s.pattern === 'string')
688+
));
689+
if (!hasConstraints) return;
690+
691+
const hasUnconstrained = candidates.some((s) => (
692+
!(Array.isArray(s.enum) || Object.prototype.hasOwnProperty.call(s, 'const')) &&
693+
!(actualType === 'string' && typeof s.pattern === 'string')
694+
));
695+
if (hasUnconstrained) return;
696+
697+
const value = parsed.value;
698+
const matchesAny = candidates.some((s) => {
699+
const enums = getSchemaEnumValues(s);
700+
if (enums.length > 0 && !enums.some((v) => valueEquals(v, value))) return false;
701+
if (actualType === 'string' && typeof s.pattern === 'string') {
702+
try {
703+
const re = new RegExp(s.pattern);
704+
if (!re.test(String(value))) return false;
705+
} catch (e) {
706+
return true;
707+
}
708+
}
709+
return true;
710+
});
711+
if (matchesAny) return;
712+
713+
const enumValues = [];
714+
const patterns = [];
715+
for (const s of candidates) {
716+
enumValues.push(...getSchemaEnumValues(s));
717+
if (actualType === 'string' && typeof s.pattern === 'string') patterns.push(s.pattern);
718+
}
719+
const enumLabel = unique(enumValues).map((v) => toYamlScalar(v)).join(', ');
720+
const patternLabel = unique(patterns).join(' | ');
721+
722+
let message;
723+
const label = keyName ? `"${keyName}"` : 'value';
724+
if (enumValues.length && patterns.length) {
725+
message = `Value for ${label} must be one of: ${enumLabel}; or match pattern: ${patternLabel}`;
726+
} else if (enumValues.length) {
727+
message = `Value for ${label} must be one of: ${enumLabel}`;
728+
} else if (patterns.length) {
729+
message = `Value for ${label} must match pattern: ${patternLabel}`;
730+
} else {
731+
return;
732+
}
733+
734+
pushMarker({
735+
severity: monaco.MarkerSeverity.Error,
736+
message,
737+
startLineNumber: lineNumber,
738+
startColumn,
739+
endLineNumber: lineNumber,
740+
endColumn: Math.max(startColumn + 1, endColumn),
741+
});
742+
};
743+
590744
for (let lineNumber = 1; lineNumber <= lineCount; lineNumber++) {
591745
let line = model.getLineContent(lineNumber);
592746
if (!line.trim()) continue;
@@ -702,12 +856,14 @@
702856
const actual = classifyYamlScalar(valueText).type;
703857
const valueStartColumn = kv.valueStartIndex + 1 + (kv.after.length - kv.after.trimStart().length);
704858
checkValueType(propSchema, actual, lineNumber, valueStartColumn, line.length + 1, kv.key);
859+
checkValueConstraints(propSchema, actual, valueText, lineNumber, valueStartColumn, line.length + 1, kv.key);
705860
}
706861
continue;
707862
}
708863

709864
const scalar = classifyYamlScalar(listItem.rest).type;
710865
checkValueType(itemSchema, scalar, lineNumber, listItem.afterDashIndex + 1, line.length + 1, null);
866+
checkValueConstraints(itemSchema, scalar, listItem.rest, lineNumber, listItem.afterDashIndex + 1, line.length + 1, null);
711867
continue;
712868
}
713869

@@ -774,6 +930,7 @@
774930
const actual = classifyYamlScalar(kv.after).type;
775931
const valueStartColumn = kv.valueStartIndex + 1 + (kv.after.length - kv.after.trimStart().length);
776932
checkValueType(propSchema, actual, lineNumber, valueStartColumn, line.length + 1, kv.key);
933+
checkValueConstraints(propSchema, actual, kv.after, lineNumber, valueStartColumn, line.length + 1, kv.key);
777934
}
778935

779936
return markers;
@@ -910,11 +1067,12 @@
9101067
const props = getObjectProperties(contextSchema);
9111068
const suggestions = Object.keys(props).map((key) => {
9121069
const s = resolveRef(props[key]);
913-
const wantsBlock = s && (s.type === 'object' || s.type === 'array' || s.properties);
1070+
const wantsArray = s && s.type === 'array';
1071+
const wantsBlock = s && (s.type === 'object' || wantsArray || s.properties);
9141072
const indent = listItem ? ' '.repeat(listItem.contentIndent) : ' '.repeat(countIndent(lineNoComment));
9151073
const innerIndent = indent + ' ';
9161074

917-
const insertText = wantsBlock ? `${key}:\n${innerIndent}` : `${key}: `;
1075+
const insertText = wantsArray ? `${key}:\n${indent}` : (wantsBlock ? `${key}:\n${innerIndent}` : `${key}: `);
9181076
const hasValueSuggestions = !wantsBlock && getValueSuggestions(s).length > 0;
9191077

9201078
return {

0 commit comments

Comments
 (0)