diff --git a/package.json b/package.json index 56791c6c..6f2d9017 100644 --- a/package.json +++ b/package.json @@ -205,6 +205,7 @@ "dependencies": { "aurelia-cli": "^0.29.0", "aurelia-dependency-injection": "^1.3.0", + "aurelia-templating-binding": "^1.4.0", "parse5": "^3.0.1", "reflect-metadata": "^0.1.9", "vscode-languageclient": "^3.5.0", diff --git a/src/client/htmlInvalidCasingCodeActionProvider.ts b/src/client/htmlInvalidCasingCodeActionProvider.ts index c656e045..034e5a1b 100644 --- a/src/client/htmlInvalidCasingCodeActionProvider.ts +++ b/src/client/htmlInvalidCasingCodeActionProvider.ts @@ -1,48 +1,48 @@ -'use strict'; -import vscode = require('vscode'); - -export default class HtmlInvalidCasingActionProvider implements vscode.CodeActionProvider { - private static commandId: string = 'aurelia-fix-invalid-casing'; - private command: vscode.Disposable; - - public activate(subscriptions: vscode.Disposable[]) { - this.command = vscode.commands.registerCommand(HtmlInvalidCasingActionProvider.commandId, this.fixInvalidCasing, this); - subscriptions.push(this); - } - - public provideCodeActions( - document: vscode.TextDocument, - range: vscode.Range, - context: vscode.CodeActionContext, - token: vscode.CancellationToken): vscode.Command[] { - - let diagnostic: vscode.Diagnostic = context.diagnostics[0]; - let text = document.getText(diagnostic.range); - const kebabCaseValidationRegex = /(.*)\.(bind|one-way|two-way|one-time|call|delegate|trigger)/; - - let result = kebabCaseValidationRegex.exec(text); - let attribute = result[1]; - let binding = result[2]; - let fixedAttribute = attribute.split(/(?=[A-Z])/).map(s => s.toLowerCase()).join('-'); - let fixedText = `${fixedAttribute}.${binding}`; - let commands: vscode.Command[] = []; - - commands.push({ - arguments: [document, diagnostic.range, fixedText], - command: HtmlInvalidCasingActionProvider.commandId, - title: `Rename ${attribute} to ${fixedAttribute}`, - }); - - return commands; - } - - public fixInvalidCasing(document, range, fixedText) { - let edit = new vscode.WorkspaceEdit(); - edit.replace(document.uri, range, fixedText); - return vscode.workspace.applyEdit(edit); - } - - public dispose(): void { - this.command.dispose(); - } -} +'use strict'; +import vscode = require('vscode'); +import { attributeInvalidCaseFix } from './../shared/attributeInvalidCaseFix'; + +export default class HtmlInvalidCasingActionProvider implements vscode.CodeActionProvider { + private static commandId: string = 'aurelia-fix-invalid-casing'; + private command: vscode.Disposable; + + public activate(subscriptions: vscode.Disposable[]) { + this.command = vscode.commands.registerCommand(HtmlInvalidCasingActionProvider.commandId, this.fixInvalidCasing, this); + subscriptions.push(this); + } + + public provideCodeActions( + document: vscode.TextDocument, + range: vscode.Range, + context: vscode.CodeActionContext, + token: vscode.CancellationToken): vscode.Command[] { + + const diagnostic: vscode.Diagnostic = context.diagnostics[0]; + const text = document.getText(diagnostic.range); + + const elementResult = /<([a-zA-Z\-]*) .*$/g.exec(document.getText(diagnostic.range.with(new vscode.Position(0,0)))); + const elementName = elementResult[1]; + + const {attribute, command, fixed} = attributeInvalidCaseFix(elementName, text); + const fixedText = `${fixed}.${command}`; + const commands: vscode.Command[] = []; + + commands.push({ + arguments: [document, diagnostic.range, fixedText], + command: HtmlInvalidCasingActionProvider.commandId, + title: `Rename ${attribute} to ${fixed}`, + }); + + return commands; + } + + public fixInvalidCasing(document, range, fixedText) { + let edit = new vscode.WorkspaceEdit(); + edit.replace(document.uri, range, fixedText); + return vscode.workspace.applyEdit(edit); + } + + public dispose(): void { + this.command.dispose(); + } +} diff --git a/src/server/aurelia-languageservice/services/htmlValidation.ts b/src/server/aurelia-languageservice/services/htmlValidation.ts index 054b58d5..349e0df9 100644 --- a/src/server/aurelia-languageservice/services/htmlValidation.ts +++ b/src/server/aurelia-languageservice/services/htmlValidation.ts @@ -1,114 +1,105 @@ -'use strict'; -import { Diagnostic, DiagnosticSeverity, Range, TextDocument } from 'vscode-languageserver-types'; -import { LanguageSettings } from '../aureliaLanguageService'; -import { HTMLDocument } from '../parser/htmlParser'; -import { TokenType, createScanner } from '../parser/htmlScanner'; - -export type DiagnosticCodes = 'invalid-casing' | 'invalid-method'; -export const DiagnosticCodes = { - InvalidCasing: 'invalid-casing' as DiagnosticCodes, - InvalidMethod: 'invalid-method' as DiagnosticCodes, -}; - -export const DiagnosticSource = 'Aurelia'; - -interface Attribute { - name: string; - start: number; - length: number; - value?: string; -} - -const kebabCaseValidationRegex = /(.*)\.(bind|one-way|two-way|one-time|call|delegate|trigger)/; - -function camelToKebab(s: string) { - return s.replace(/\.?([A-Z])/g, (x, y) => '-' + y.toLowerCase()).replace(/^-/, ''); -} - -export class HTMLValidation { - private validationEnabled: boolean; - - constructor() { - this.validationEnabled = true; - } - - public configure(raw: LanguageSettings) { - if (raw) { - this.validationEnabled = raw.validate; - } - } - - public async doValidation(document: TextDocument, htmlDocument: HTMLDocument): Promise { - if (!this.validationEnabled) { - return Promise.resolve([]); - } - - // handle empty document cases - if (!htmlDocument || !htmlDocument.roots || htmlDocument.roots.length === 0) { - return Promise.resolve([]); - } - - const text = document.getText(); - const scanner = createScanner(text, htmlDocument.roots[0].start); - - const diagnostics: Diagnostic[] = []; - - let attr; - let token = scanner.scan(); - while (token !== TokenType.EOS) { - // tslint:disable-next-line:switch-default - switch (token) { - case TokenType.AttributeName: - attr = { - length: scanner.getTokenLength(), - name: scanner.getTokenText(), - start: scanner.getTokenOffset(), - }; - break; - case TokenType.AttributeValue: - attr.value = scanner.getTokenText(); - await this.validateAttribute(attr, document, diagnostics); - break; - } - token = scanner.scan(); - } - - return Promise.resolve(diagnostics); - } - - private async validateAttribute(attr: Attribute, document: TextDocument, diagnostics: Diagnostic[]) { - let match = kebabCaseValidationRegex.exec(attr.name); - if (match && match.length) { - const prop = match[1]; - const op = match[2]; - - if (prop !== prop.toLowerCase()) { - diagnostics.push(this.toDiagnostic(attr, document, - `'${attr.name}' has invalid casing; it should likely be '${camelToKebab(prop)}.${op}'`, - DiagnosticCodes.InvalidCasing)); - } - } - - return Promise.resolve(); - } - - private toDiagnostic( - attr: Attribute, - document: TextDocument, - message: string, - code: DiagnosticCodes | undefined = undefined, - serverity: DiagnosticSeverity = DiagnosticSeverity.Error): Diagnostic { - const range = Range.create(document.positionAt(attr.start), document.positionAt(attr.start + attr.length)); - const diagnostic = { - message: message, - range: range, - severity: serverity, - source: DiagnosticSource, - }; - - if (code !== undefined) { - diagnostic.code = code; - } - return diagnostic; - } -} +'use strict'; +import { Diagnostic, DiagnosticSeverity, Range, TextDocument } from 'vscode-languageserver-types'; +import { LanguageSettings } from '../aureliaLanguageService'; +import { HTMLDocument } from '../parser/htmlParser'; +import { TokenType, createScanner } from '../parser/htmlScanner'; +import { attributeInvalidCaseFix } from './../../../shared/attributeInvalidCaseFix'; + +export type DiagnosticCodes = 'invalid-casing' | 'invalid-method'; +export const DiagnosticCodes = { + InvalidCasing: 'invalid-casing' as DiagnosticCodes, + InvalidMethod: 'invalid-method' as DiagnosticCodes, +}; + +export const DiagnosticSource = 'Aurelia'; + +interface Attribute { + name: string; + start: number; + length: number; + value?: string; +} + +export class HTMLValidation { + private validationEnabled: boolean; + + constructor() { + this.validationEnabled = true + } + + public configure(raw: LanguageSettings) { + if (raw) { + this.validationEnabled = raw.validate; + } + } + + public async doValidation(document: TextDocument, htmlDocument: HTMLDocument): Promise { + if (!this.validationEnabled) { + return Promise.resolve([]); + } + + // handle empty document cases + if (!htmlDocument || !htmlDocument.roots || htmlDocument.roots.length === 0) { + return Promise.resolve([]); + } + + const text = document.getText(); + const scanner = createScanner(text, htmlDocument.roots[0].start); + const diagnostics: Diagnostic[] = []; + + let attr; + let token = scanner.scan(); + let elementName = ''; + while (token !== TokenType.EOS) { + // tslint:disable-next-line:switch-default + switch (token) { + case TokenType.AttributeName: + attr = { + length: scanner.getTokenLength(), + name: scanner.getTokenText(), + start: scanner.getTokenOffset(), + }; + break; + case TokenType.AttributeValue: + attr.value = scanner.getTokenText(); + this.validateAttribute(attr, elementName, document, diagnostics); + break; + case TokenType.StartTag: + elementName = scanner.getTokenText(); + break; + } + token = scanner.scan(); + } + + return Promise.resolve(diagnostics); + } + + private validateAttribute(attr: Attribute, elementName: string, document: TextDocument, diagnostics: Diagnostic[]) { + const {attribute, command, fixed} = attributeInvalidCaseFix(elementName, attr.name); + if(fixed && fixed !== attribute) { + diagnostics.push(this.toDiagnostic(attr, document, + `'${attr.name}' has invalid casing; it should likely be '${fixed}.${command}'`, + DiagnosticCodes.InvalidCasing)); + } + } + + private toDiagnostic( + attr: Attribute, + document: TextDocument, + message: string, + code: DiagnosticCodes | undefined = undefined, + serverity: DiagnosticSeverity = DiagnosticSeverity.Error): Diagnostic { + const range = Range.create(document.positionAt(attr.start), document.positionAt(attr.start + attr.length)); + const diagnostic = { + message: message, + range: range, + severity: serverity, + source: DiagnosticSource, + }; + + if (code !== undefined) { + diagnostic.code = code; + } + return diagnostic; + } +} diff --git a/src/shared/attributeInvalidCaseFix.ts b/src/shared/attributeInvalidCaseFix.ts new file mode 100644 index 00000000..220ae9ff --- /dev/null +++ b/src/shared/attributeInvalidCaseFix.ts @@ -0,0 +1,46 @@ +import { AttributeMap } from 'aurelia-templating-binding'; + +export function attributeInvalidCaseFix(elementName, attributeText) { + + const attributeMap = new AttributeMap({ + isStandardSvgAttribute: () => false + }); + + const kebabCaseValidationRegex = /(.*)\.(bind|one-way|two-way|one-time|from-view|to-view|ref|call|delegate|trigger)/; + const match = kebabCaseValidationRegex.exec(attributeText); + if (match) { + const attribute = match[1]; + const command = match[2]; + + let fixed; + const element = attributeMap.elements[elementName]; + + // only replace dashes in non data-* and aria-* attributes + let lookupProperty = attribute.toLowerCase(); + if (/^(?!(data|aria)-).*$/g.test(lookupProperty)) { + lookupProperty = lookupProperty.replace('-',''); + } + + if (element && lookupProperty in element) { + fixed = element[lookupProperty]; + } + else if (lookupProperty in attributeMap.allElements) { + fixed = attributeMap.allElements[lookupProperty]; + } + else { + fixed = attribute.split(/(?=[A-Z])/).map(s => s.toLowerCase()).join('-'); + } + + return { + attribute, + command, + fixed + }; + } + + return { + attribute: attributeText, + command: undefined, + fixed: undefined + }; +} diff --git a/tsconfig.json b/tsconfig.json index ecca489c..e1358bb2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,18 +1,19 @@ -{ - "compilerOptions": { - "module": "commonjs", - "target": "es6", - "outDir": "dist", - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "lib": [ - "es6" - ], - "sourceMap": true, - "rootDir": "." - }, - "exclude": [ - "node_modules", - ".vscode-test" - ] -} +{ + "compilerOptions": { + "module": "commonjs", + "target": "es6", + "outDir": "dist", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "lib": [ + "es6", + "dom" + ], + "sourceMap": true, + "rootDir": "." + }, + "exclude": [ + "node_modules", + ".vscode-test" + ] +}