diff --git a/package.json b/package.json index 8879f6544..9ed6864bc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "yaml-language-server", "description": "YAML language server", - "version": "1.17.0", + "version": "1.18.0", "author": "Red Hat", "license": "MIT", "contributors": [ diff --git a/src/languageservice/jsonASTTypes.ts b/src/languageservice/jsonASTTypes.ts index fa9bb8169..6a8827efa 100644 --- a/src/languageservice/jsonASTTypes.ts +++ b/src/languageservice/jsonASTTypes.ts @@ -56,6 +56,7 @@ export interface NumberASTNode extends BaseASTNode { export interface BooleanASTNode extends BaseASTNode { readonly type: 'boolean'; readonly value: boolean; + readonly source: string; } export interface NullASTNode extends BaseASTNode { readonly type: 'null'; diff --git a/src/languageservice/parser/ast-converter.ts b/src/languageservice/parser/ast-converter.ts index 241a7e4da..ae15588f0 100644 --- a/src/languageservice/parser/ast-converter.ts +++ b/src/languageservice/parser/ast-converter.ts @@ -137,7 +137,7 @@ function convertScalar(node: Scalar, parent: ASTNode): ASTNode { return result; } case 'boolean': - return new BooleanASTNodeImpl(parent, node, node.value, ...toOffsetLength(node.range)); + return new BooleanASTNodeImpl(parent, node, node.value, node.source, ...toOffsetLength(node.range)); case 'number': { const result = new NumberASTNodeImpl(parent, node, ...toOffsetLength(node.range)); result.value = node.value; diff --git a/src/languageservice/parser/jsonParser07.ts b/src/languageservice/parser/jsonParser07.ts index 537a9beb6..208daa7d5 100644 --- a/src/languageservice/parser/jsonParser07.ts +++ b/src/languageservice/parser/jsonParser07.ts @@ -27,6 +27,7 @@ import { isArrayEqual } from '../utils/arrUtils'; import { Node, Pair } from 'yaml'; import { safeCreateUnicodeRegExp } from '../utils/strings'; import { FilePatternAssociation } from '../services/yamlSchemaService'; +import { floatSafeRemainder } from '../utils/math'; const localize = nls.loadMessageBundle(); const MSG_PROPERTY_NOT_ALLOWED = 'Property {0} is not allowed.'; @@ -168,10 +169,12 @@ export class NullASTNodeImpl extends ASTNodeImpl implements NullASTNode { export class BooleanASTNodeImpl extends ASTNodeImpl implements BooleanASTNode { public type: 'boolean' = 'boolean' as const; public value: boolean; + public source: string; - constructor(parent: ASTNode, internalNode: Node, boolValue: boolean, offset: number, length?: number) { + constructor(parent: ASTNode, internalNode: Node, boolValue: boolean, boolSource: string, offset: number, length?: number) { super(parent, internalNode, offset, length); this.value = boolValue; + this.source = boolSource; } } @@ -501,8 +504,9 @@ export function getNodeValue(node: ASTNode): any { case 'null': case 'string': case 'number': - case 'boolean': return node.value; + case 'boolean': + return node.source; default: return undefined; } @@ -533,6 +537,12 @@ export function findNodeAtOffset(node: ASTNode, offset: number, includeRightBoun return undefined; } +interface IValidationMatch { + schema: JSONSchema; + validationResult: ValidationResult; + matchingSchemas: ISchemaCollector; +} + export class JSONDocument { public isKubernetes: boolean; public disableAdditionalProperties: boolean; @@ -866,7 +876,7 @@ function validate( const val = getNodeValue(node); let enumValueMatch = false; for (const e of schema.enum) { - if (equals(val, e) || (callFromAutoComplete && isString(val) && isString(e) && val && e.startsWith(val))) { + if (val === e || isAutoCompleteEqualMaybe(callFromAutoComplete, node, val, e)) { enumValueMatch = true; break; } @@ -898,10 +908,7 @@ function validate( if (isDefined(schema.const)) { const val = getNodeValue(node); - if ( - !equals(val, schema.const) && - !(callFromAutoComplete && isString(val) && isString(schema.const) && schema.const.startsWith(val)) - ) { + if (!equals(val, schema.const) && !isAutoCompleteEqualMaybe(callFromAutoComplete, node, val, schema.const)) { validationResult.problems.push({ location: { offset: node.offset, length: node.length }, severity: DiagnosticSeverity.Warning, @@ -935,7 +942,7 @@ function validate( const val = node.value; if (isNumber(schema.multipleOf)) { - if (val % schema.multipleOf !== 0) { + if (floatSafeRemainder(val, schema.multipleOf) !== 0) { validationResult.problems.push({ location: { offset: node.offset, length: node.length }, severity: DiagnosticSeverity.Warning, @@ -1504,23 +1511,11 @@ function validate( node: ASTNode, maxOneMatch, subValidationResult: ValidationResult, - bestMatch: { - schema: JSONSchema; - validationResult: ValidationResult; - matchingSchemas: ISchemaCollector; - }, + bestMatch: IValidationMatch, subSchema, subMatchingSchemas - ): { - schema: JSONSchema; - validationResult: ValidationResult; - matchingSchemas: ISchemaCollector; - } { - if ( - !maxOneMatch && - !subValidationResult.hasProblems() && - (!bestMatch.validationResult.hasProblems() || callFromAutoComplete) - ) { + ): IValidationMatch { + if (!maxOneMatch && !subValidationResult.hasProblems() && !bestMatch.validationResult.hasProblems()) { // no errors, both are equally good matches bestMatch.matchingSchemas.merge(subMatchingSchemas); bestMatch.validationResult.propertiesMatches += subValidationResult.propertiesMatches; @@ -1541,19 +1536,30 @@ function validate( validationResult: subValidationResult, matchingSchemas: subMatchingSchemas, }; - } else if (compareResult === 0) { + } else if ( + compareResult === 0 || + ((node.value === null || node.type === 'null') && node.length === 0) // node with no value can match any schema potentially + ) { // there's already a best matching but we are as good - bestMatch.matchingSchemas.merge(subMatchingSchemas); - bestMatch.validationResult.mergeEnumValues(subValidationResult); - bestMatch.validationResult.mergeWarningGeneric(subValidationResult, [ - ProblemType.missingRequiredPropWarning, - ProblemType.typeMismatchWarning, - ProblemType.constWarning, - ]); + mergeValidationMatches(bestMatch, subMatchingSchemas, subValidationResult); } } return bestMatch; } + + function mergeValidationMatches( + bestMatch: IValidationMatch, + subMatchingSchemas: ISchemaCollector, + subValidationResult: ValidationResult + ): void { + bestMatch.matchingSchemas.merge(subMatchingSchemas); + bestMatch.validationResult.mergeEnumValues(subValidationResult); + bestMatch.validationResult.mergeWarningGeneric(subValidationResult, [ + ProblemType.missingRequiredPropWarning, + ProblemType.typeMismatchWarning, + ProblemType.constWarning, + ]); + } } function getSchemaSource(schema: JSONSchema, originalSchema: JSONSchema): string | undefined { @@ -1591,3 +1597,26 @@ function getSchemaUri(schema: JSONSchema, originalSchema: JSONSchema): string[] function getWarningMessage(problemType: ProblemType, args: string[]): string { return localize(problemType, ProblemTypeMessages[problemType], args.join(' | ')); } + +/** + * if callFromAutoComplete than compare value from yaml and value from schema (s.const | s.enum[i]) + * allows partial match for autocompletion + */ +function isAutoCompleteEqualMaybe( + callFromAutoComplete: boolean, + node: ASTNode, + nodeValue: unknown, + schemaValue: unknown +): boolean { + if (!callFromAutoComplete) { + return false; + } + + // if autocompletion property doesn't have value, then it could be a match + const isWithoutValue = nodeValue === null && node.length === 0; // allows `prop: ` but ignore `prop: null` + if (isWithoutValue) { + return true; + } + + return isString(nodeValue) && isString(schemaValue) && schemaValue.startsWith(nodeValue); +} diff --git a/src/languageservice/services/yamlCompletion.ts b/src/languageservice/services/yamlCompletion.ts index e0e828798..c5afa9256 100644 --- a/src/languageservice/services/yamlCompletion.ts +++ b/src/languageservice/services/yamlCompletion.ts @@ -37,6 +37,7 @@ import { indexOf, isInComment, isMapContainsEmptyPair } from '../utils/astUtils' import { isModeline } from './modelineUtil'; import { getSchemaTypeName, isAnyOfAllOfOneOfType, isPrimitiveType } from '../utils/schemaUtils'; import { YamlNode } from '../jsonASTTypes'; +import { SettingsState } from '../../yamlSettings'; const localize = nls.loadMessageBundle(); @@ -74,6 +75,7 @@ export class YamlCompletion { private completionEnabled = true; private configuredIndentation: string | undefined; private yamlVersion: YamlVersion; + private isSingleQuote: boolean; private indentation: string; private arrayPrefixIndentation = ''; private supportsMarkdown: boolean | undefined; @@ -87,12 +89,13 @@ export class YamlCompletion { private readonly telemetry?: Telemetry ) {} - configure(languageSettings: LanguageSettings): void { + configure(languageSettings: LanguageSettings, yamlSettings?: SettingsState): void { if (languageSettings) { this.completionEnabled = languageSettings.completion; } this.customTags = languageSettings.customTags; this.yamlVersion = languageSettings.yamlVersion; + this.isSingleQuote = yamlSettings?.yamlFormatterSettings?.singleQuote || false; this.configuredIndentation = languageSettings.indentation; this.disableDefaultProperties = languageSettings.disableDefaultProperties; this.parentSkeletonSelectedFirst = languageSettings.parentSkeletonSelectedFirst; @@ -240,12 +243,29 @@ export class YamlCompletion { } } - // trim $1 from end of completion - if (completionItem.insertText.endsWith('$1') && !isForParentCompletion) { - completionItem.insertText = completionItem.insertText.substr(0, completionItem.insertText.length - 2); - } - if (overwriteRange && overwriteRange.start.line === overwriteRange.end.line) { - completionItem.textEdit = TextEdit.replace(overwriteRange, completionItem.insertText); + if (completionItem.label.toLowerCase() === 'regular expression') { + const docObject = completionItem.documentation as MarkupContent; + const splitValues = docObject.value.split(':'); + label = + splitValues.length > 0 ? `${this.getQuote()}\\${JSON.parse(splitValues[1])}${this.getQuote()}` : completionItem.label; + completionItem.insertText = label; + completionItem.textEdit = TextEdit.replace(overwriteRange, label); + } else { + let mdText = completionItem.insertText.replace(/\${[0-9]+[:|](.*)}/g, (s, arg) => arg).replace(/\$([0-9]+)/g, ''); + const splitMDText = mdText.split(':'); + let value = splitMDText.length > 1 ? splitMDText[1].trim() : mdText; + if (value && /^(['\\"\\])$/.test(value)) { + value = `${this.getQuote()}\\${value}${this.getQuote()}`; + mdText = splitMDText.length > 1 ? splitMDText[0] + ': ' + value : value; + completionItem.insertText = mdText; + } + // trim $1 from end of completion + if (completionItem.insertText.endsWith('$1') && !isForParentCompletion) { + completionItem.insertText = completionItem.insertText.substr(0, completionItem.insertText.length - 2); + } + if (overwriteRange && overwriteRange.start.line === overwriteRange.end.line) { + completionItem.textEdit = TextEdit.replace(overwriteRange, completionItem.insertText); + } } completionItem.label = label; @@ -622,7 +642,7 @@ export class YamlCompletion { }; result.items.forEach((completionItem) => { - if (isParentCompletionItem(completionItem)) { + if (this.isParentCompletionItem(completionItem)) { const indent = completionItem.parent.indent || ''; const reindexedTexts = reindexText(completionItem.parent.insertTexts); @@ -1021,7 +1041,7 @@ export class YamlCompletion { if (propertySchema.const) { if (!value) { value = this.getInsertTextForGuessedValue(propertySchema.const, '', type); - value = evaluateTab1Symbol(value); // prevent const being selected after snippet insert + value = this.evaluateTab1Symbol(value); // prevent const being selected after snippet insert value = ' ' + value; } nValueProposals++; @@ -1117,7 +1137,7 @@ export class YamlCompletion { let value = propertySchema.default || propertySchema.const; if (value) { if (type === 'string') { - value = convertToStringValue(value); + value = this.convertToStringValue(value); } insertText += `${indent}${key}: \${${insertIndex++}:${value}}\n`; } else { @@ -1165,7 +1185,7 @@ export class YamlCompletion { }: \${${insertIndex++}:${propertySchema.default}}\n`; break; case 'string': - insertText += `${indent}${key}: \${${insertIndex++}:${convertToStringValue(propertySchema.default)}}\n`; + insertText += `${indent}${key}: \${${insertIndex++}:${this.convertToStringValue(propertySchema.default)}}\n`; break; case 'array': case 'object': @@ -1232,7 +1252,7 @@ export class YamlCompletion { snippetValue = snippetValue.substr(1, snippetValue.length - 2); // remove quotes snippetValue = this.getInsertTextForPlainText(snippetValue); // escape \ and } if (type === 'string') { - snippetValue = convertToStringValue(snippetValue); + snippetValue = this.convertToStringValue(snippetValue); } return '${1:' + snippetValue + '}' + separatorAfter; } @@ -1263,7 +1283,7 @@ export class YamlCompletion { } type = Array.isArray(type) ? type[0] : type; if (type === 'string') { - value = convertToStringValue(value); + value = this.convertToStringValue(value); } return this.getInsertTextForPlainText(value + separatorAfter); } @@ -1667,65 +1687,72 @@ export class YamlCompletion { return 0; } -} -const isNumberExp = /^\d+$/; -function convertToStringValue(param: unknown): string { - let value: string; - if (typeof param === 'string') { - value = param; - } else { - value = '' + param; - } - if (value.length === 0) { - return value; - } + isNumberExp = /^\d+$/; + convertToStringValue(param: unknown): string { + let value: string; + if (typeof param === 'string') { + //support YAML spec 1.1 boolean values + value = ['on', 'off', 'true', 'false', 'yes', 'no'].includes(param.toLowerCase()) + ? `${this.getQuote()}${param}${this.getQuote()}` + : param; + } else { + value = '' + param; + } + if (value.length === 0) { + return value; + } - if (value === 'true' || value === 'false' || value === 'null' || isNumberExp.test(value)) { - return `"${value}"`; - } + if (value === 'true' || value === 'false' || value === 'null' || this.isNumberExp.test(value)) { + return `"${value}"`; + } - if (value.indexOf('"') !== -1) { - value = value.replace(doubleQuotesEscapeRegExp, '"'); - } + if (value.indexOf('"') !== -1) { + value = value.replace(doubleQuotesEscapeRegExp, '"'); + } - let doQuote = !isNaN(parseInt(value)) || value.charAt(0) === '@'; + let doQuote = !isNaN(parseInt(value)) || value.charAt(0) === '@'; + + if (!doQuote) { + // need to quote value if in `foo: bar`, `foo : bar` (mapping) or `foo:` (partial map) format + // but `foo:bar` and `:bar` (colon without white-space after it) are just plain string + let idx = value.indexOf(':', 0); + for (; idx > 0 && idx < value.length; idx = value.indexOf(':', idx + 1)) { + if (idx === value.length - 1) { + // `foo:` (partial map) format + doQuote = true; + break; + } - if (!doQuote) { - // need to quote value if in `foo: bar`, `foo : bar` (mapping) or `foo:` (partial map) format - // but `foo:bar` and `:bar` (colon without white-space after it) are just plain string - let idx = value.indexOf(':', 0); - for (; idx > 0 && idx < value.length; idx = value.indexOf(':', idx + 1)) { - if (idx === value.length - 1) { - // `foo:` (partial map) format - doQuote = true; - break; + // there are only two valid kinds of white-space in yaml: space or tab + // ref: https://yaml.org/spec/1.2.1/#id2775170 + const nextChar = value.charAt(idx + 1); + if (nextChar === '\t' || nextChar === ' ') { + doQuote = true; + break; + } } + } - // there are only two valid kinds of white-space in yaml: space or tab - // ref: https://yaml.org/spec/1.2.1/#id2775170 - const nextChar = value.charAt(idx + 1); - if (nextChar === '\t' || nextChar === ' ') { - doQuote = true; - break; - } + if (doQuote) { + value = `"${value}"`; } - } - if (doQuote) { - value = `"${value}"`; + return value; } - return value; -} + getQuote(): string { + return this.isSingleQuote ? `'` : `"`; + } -/** - * simplify `{$1:value}` to `value` - */ -function evaluateTab1Symbol(value: string): string { - return value.replace(/\$\{1:(.*)\}/, '$1'); -} + /** + * simplify `{$1:value}` to `value` + */ + evaluateTab1Symbol(value: string): string { + return value.replace(/\$\{1:(.*)\}/, '$1'); + } -function isParentCompletionItem(item: CompletionItemBase): item is CompletionItem { - return 'parent' in item; + isParentCompletionItem(item: CompletionItemBase): item is CompletionItem { + return 'parent' in item; + } } diff --git a/src/languageservice/services/yamlFormatter.ts b/src/languageservice/services/yamlFormatter.ts index 2d9bcaf60..a3cb6ea67 100644 --- a/src/languageservice/services/yamlFormatter.ts +++ b/src/languageservice/services/yamlFormatter.ts @@ -6,8 +6,9 @@ import { Range, Position, TextEdit, FormattingOptions } from 'vscode-languageserver-types'; import { CustomFormatterOptions, LanguageSettings } from '../yamlLanguageService'; -import { format, Options } from 'prettier'; +import { Options } from 'prettier'; import * as parser from 'prettier/plugins/yaml'; +import { format } from 'prettier/standalone'; import { TextDocument } from 'vscode-languageserver-textdocument'; export class YAMLFormatter { diff --git a/src/languageservice/utils/math.ts b/src/languageservice/utils/math.ts new file mode 100644 index 000000000..36fe87e42 --- /dev/null +++ b/src/languageservice/utils/math.ts @@ -0,0 +1,8 @@ +export function floatSafeRemainder(val: number, step: number): number { + const valDecCount = (val.toString().split('.')[1] || '').length; + const stepDecCount = (step.toString().split('.')[1] || '').length; + const decCount = Math.max(valDecCount, stepDecCount); + const valInt = parseInt(val.toFixed(decCount).replace('.', '')); + const stepInt = parseInt(step.toFixed(decCount).replace('.', '')); + return (valInt % stepInt) / Math.pow(10, decCount); +} diff --git a/src/languageservice/yamlLanguageService.ts b/src/languageservice/yamlLanguageService.ts index 457a9d09e..b76688db1 100644 --- a/src/languageservice/yamlLanguageService.ts +++ b/src/languageservice/yamlLanguageService.ts @@ -222,7 +222,7 @@ export function getLanguageService(params: { } yamlValidation.configure(settings); hover.configure(settings); - completer.configure(settings); + completer.configure(settings, params.yamlSettings); formatter.configure(settings); yamlCodeActions.configure(settings); }, diff --git a/test/autoCompletionFix.test.ts b/test/autoCompletionFix.test.ts index d8ec5b040..bcb28bea3 100644 --- a/test/autoCompletionFix.test.ts +++ b/test/autoCompletionFix.test.ts @@ -759,6 +759,157 @@ objB: expect(completion.items[0].label).to.be.equal('prop'); expect(completion.items[0].insertText).to.be.equal('prop: ${1|const value,null|}'); }); + it('should take all sub-schemas when value has not been set (cursor in the middle of the empty space)', async () => { + const schema: JSONSchema = { + anyOf: [ + { + properties: { + prop: { type: 'null' }, + }, + }, + { + properties: { + prop: { const: 'const value' }, + }, + }, + { + properties: { + prop: { const: 5 }, + }, + }, + ], + }; + schemaProvider.addSchema(SCHEMA_ID, schema); + const content = 'prop: | | '; + const completion = await parseCaret(content); + expect(completion.items.map((i) => i.label)).to.be.deep.eq(['const value', '5', 'null']); + }); + it('should take only null sub-schema when value is "null"', async () => { + const schema: JSONSchema = { + anyOf: [ + { + properties: { + prop: { type: 'null' }, + }, + }, + { + properties: { + prop: { const: 'const value' }, + }, + }, + ], + }; + schemaProvider.addSchema(SCHEMA_ID, schema); + const content = 'prop: null'; + const completion = await parseSetup(content, 0, content.length); + expect(completion.items.map((i) => i.label)).to.be.deep.eq(['null']); + }); + it('should take only one sub-schema because first sub-schema does not match', async () => { + const schema: JSONSchema = { + anyOf: [ + { + properties: { + prop: { const: 'const value' }, + }, + }, + { + properties: { + prop: { const: 'const value2' }, + }, + }, + ], + }; + schemaProvider.addSchema(SCHEMA_ID, schema); + const content = 'prop: const value2'; + const completion = await parseSetup(content, 0, content.length); + expect(completion.items.map((i) => i.label)).to.be.deep.eq(['const value2']); + }); + it('should match only second sub-schema because the first one does not match', async () => { + const schema: JSONSchema = { + anyOf: [ + { + properties: { + prop: { + const: 'typeA', + }, + propA: {}, + }, + }, + { + properties: { + prop: { + const: 'typeB', + }, + propB: {}, + }, + }, + ], + }; + schemaProvider.addSchema(SCHEMA_ID, schema); + const content = 'prop: typeB\n|\n|'; + const completion = await parseCaret(content); + expect(completion.items.map((i) => i.label)).to.be.deep.eq(['propB']); + }); + it('should suggest from all sub-schemas even if nodes properties match better other schema', async () => { + // this is a case when we have a better match in the second schema but we should still suggest from the first one + // it works because `prop: ` will evaluate to `enumValueMatch = true` for both schemas + const schema: JSONSchema = { + anyOf: [ + { + properties: { + prop: { + const: 'typeA', + }, + }, + }, + { + properties: { + prop: { + const: 'typeB', + }, + propB: {}, + }, + }, + ], + }; + schemaProvider.addSchema(SCHEMA_ID, schema); + const content = 'prop: |\n|\npropB: B'; + const completion = await parseCaret(content); + expect(completion.items.map((i) => i.label)).to.be.deep.eq(['typeA', 'typeB'], 'with null value'); + + const content2 = 'prop: typ|\n|\npropB: B'; + const completion2 = await parseCaret(content2); + expect(completion2.items.map((i) => i.label)).to.be.deep.eq(['typeA', 'typeB'], 'with prefix value'); + }); + + it('should suggest both sub-schemas for anyof array', async () => { + const schema: JSONSchema = { + properties: { + entities: { + type: 'array', + items: { + anyOf: [ + { + enum: ['enum1'], + }, + { + type: 'object', + title: 'entity object', + properties: { + entityProp: { type: 'string' }, + }, + required: ['entityProp'], + }, + ], + }, + }, + }, + }; + schemaProvider.addSchema(SCHEMA_ID, schema); + const content = 'entities:\n - |\n|'; + const completion = await parseCaret(content); + expect(completion.items.map((i) => i.label)).to.be.deep.eq(['enum1', 'entityProp', 'entity object']); + }); }); describe('extra space after cursor', () => { @@ -1329,4 +1480,44 @@ test1: expect(completion.items[0].insertText).to.be.equal('${1:property}: '); expect(completion.items[0].documentation).to.be.equal('Property Description'); }); + it('should suggest enum based on type', async () => { + const schema: JSONSchema = { + type: 'object', + additionalProperties: false, + properties: { + test: { + type: 'string', + enum: ['YES', 'NO'], + }, + }, + }; + schemaProvider.addSchema(SCHEMA_ID, schema); + const content = 'test: '; + const completion = await parseSetup(content, 0, content.length); + expect(completion.items.length).equal(2); + expect(completion.items[0].insertText).to.be.equal('"YES"'); + expect(completion.items[1].insertText).to.be.equal('"NO"'); + }); + it('should suggest quotes with escapeChars', async () => { + const schema: JSONSchema = { + type: 'object', + additionalProperties: false, + properties: { + begin: { + type: 'string', + default: '\\"', + }, + }, + }; + schemaProvider.addSchema(SCHEMA_ID, schema); + let content = 'be'; + let completion = await parseSetup(content, 0, content.length); + expect(completion.items.length).equal(1); + expect(completion.items[0].insertText).to.be.equal('begin: "\\""'); + + content = 'begin: '; + completion = await parseSetup(content, 0, content.length); + expect(completion.items.length).equal(1); + expect(completion.items[0].insertText).to.be.equal('"\\""'); + }); }); diff --git a/test/jsonParser.test.ts b/test/jsonParser.test.ts index 4b10d5efc..4029a7e49 100644 --- a/test/jsonParser.test.ts +++ b/test/jsonParser.test.ts @@ -1541,6 +1541,28 @@ describe('JSON Parser', () => { } }); + it('multipleOfFloat', function () { + const schema: JsonSchema.JSONSchema = { + type: 'array', + items: { + type: 'number', + multipleOf: 0.05, + }, + }; + { + const { textDoc, jsonDoc } = toDocument('[0.9]'); + const semanticErrors = jsonDoc.validate(textDoc, schema); + + assert.strictEqual(semanticErrors.length, 0); + } + { + const { textDoc, jsonDoc } = toDocument('[42.3222222]'); + const semanticErrors = jsonDoc.validate(textDoc, schema); + + assert.strictEqual(semanticErrors.length, 1); + } + }); + it('dependencies with array', function () { const schema: JsonSchema.JSONSchema = { type: 'object',