diff --git a/extensions/emmet/package.json b/extensions/emmet/package.json index 226d2243b4472a..4b532b45d405bb 100644 --- a/extensions/emmet/package.json +++ b/extensions/emmet/package.json @@ -48,7 +48,17 @@ "emmet.includeLanguages": { "type": "object", "additionalProperties": { - "type": "string" + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "default": {}, "markdownDescription": "%emmetIncludeLanguages%" diff --git a/extensions/emmet/src/abbreviationActions.ts b/extensions/emmet/src/abbreviationActions.ts index 3326722905c6bc..b31820df3cd139 100644 --- a/extensions/emmet/src/abbreviationActions.ts +++ b/extensions/emmet/src/abbreviationActions.ts @@ -699,6 +699,11 @@ function expandAbbr(input: ExpandAbbreviationInput): string | undefined { return expandedText; } +/** + * Gets the Emmet syntax from command arguments + * @param args Command arguments containing language and parentMode information + * @returns The Emmet syntax string or undefined if not applicable + */ export function getSyntaxFromArgs(args: { [x: string]: string }): string | undefined { const mappedModes = getMappingForIncludedLanguages(); const language: string = args['language']; @@ -708,9 +713,48 @@ export function getSyntaxFromArgs(args: { [x: string]: string }): string | undef return; } - let syntax = getEmmetMode(mappedModes[language] ?? language, mappedModes, excludedLanguages); + let syntax: string | undefined; + const languageMapping = mappedModes[language]; + + if (languageMapping) { + if (typeof languageMapping === 'string') { + // Handle single string mapping (backward compatibility) + syntax = getEmmetMode(languageMapping, mappedModes, excludedLanguages); + } else if (Array.isArray(languageMapping)) { + // Handle array of languages (new feature) + // Try each language in the array until we find a valid syntax + for (const lang of languageMapping) { + const mode = getEmmetMode(lang, mappedModes, excludedLanguages); + if (mode) { + syntax = mode; + break; + } + } + } + } + if (!syntax) { - syntax = getEmmetMode(mappedModes[parentMode] ?? parentMode, mappedModes, excludedLanguages); + syntax = getEmmetMode(language, mappedModes, excludedLanguages); + } + + if (!syntax) { + const parentMapping = mappedModes[parentMode]; + if (parentMapping) { + if (typeof parentMapping === 'string') { + syntax = getEmmetMode(parentMapping, mappedModes, excludedLanguages); + } else if (Array.isArray(parentMapping)) { + for (const lang of parentMapping) { + const mode = getEmmetMode(lang, mappedModes, excludedLanguages); + if (mode) { + syntax = mode; + break; + } + } + } + } + if (!syntax) { + syntax = getEmmetMode(parentMode, mappedModes, excludedLanguages); + } } return syntax; diff --git a/extensions/emmet/src/defaultCompletionProvider.ts b/extensions/emmet/src/defaultCompletionProvider.ts index 0876cfa6f6a3e1..1bed5203566ba0 100644 --- a/extensions/emmet/src/defaultCompletionProvider.ts +++ b/extensions/emmet/src/defaultCompletionProvider.ts @@ -40,6 +40,13 @@ export class DefaultCompletionItemProvider implements vscode.CompletionItemProvi }); } + /** + * Internal method to provide completion items for Emmet abbreviations + * @param document The text document to provide completions for + * @param position The position in the document to provide completions at + * @param context The completion context + * @returns A promise resolving to completion items or undefined + */ private provideCompletionItemsInternal(document: vscode.TextDocument, position: vscode.Position, context: vscode.CompletionContext): Thenable | undefined { const emmetConfig = vscode.workspace.getConfiguration('emmet'); const excludedLanguages = emmetConfig['excludeLanguages'] ? emmetConfig['excludeLanguages'] : []; @@ -48,8 +55,28 @@ export class DefaultCompletionItemProvider implements vscode.CompletionItemProvi } const mappedLanguages = getMappingForIncludedLanguages(); - const isSyntaxMapped = mappedLanguages[document.languageId] ? true : false; - const emmetMode = getEmmetMode((isSyntaxMapped ? mappedLanguages[document.languageId] : document.languageId), mappedLanguages, excludedLanguages); + const mapping = mappedLanguages[document.languageId]; + const isSyntaxMapped = !!mapping; + + let emmetMode: string | undefined; + if (isSyntaxMapped) { + if (typeof mapping === 'string') { + // Handle single string mapping (backward compatibility) + emmetMode = getEmmetMode(mapping, mappedLanguages, excludedLanguages); + } else if (Array.isArray(mapping)) { + // Handle array of languages (new feature) + // Try each language in the array until we find a valid Emmet mode + for (const lang of mapping) { + const mode = getEmmetMode(lang, mappedLanguages, excludedLanguages); + if (mode) { + emmetMode = mode; + break; + } + } + } + } else { + emmetMode = getEmmetMode(document.languageId, mappedLanguages, excludedLanguages); + } if (!emmetMode || emmetConfig['showExpandedAbbreviation'] === 'never' diff --git a/extensions/emmet/src/emmetCommon.ts b/extensions/emmet/src/emmetCommon.ts index da364e19fa43c0..0f17e3bb5ca991 100644 --- a/extensions/emmet/src/emmetCommon.ts +++ b/extensions/emmet/src/emmetCommon.ts @@ -157,9 +157,24 @@ export function activateEmmetExtension(context: vscode.ExtensionContext) { /** * Holds any registered completion providers by their language strings */ -const languageMappingForCompletionProviders: Map = new Map(); +const languageMappingForCompletionProviders: Map = new Map(); const completionProviderDisposables: vscode.Disposable[] = []; +/** + * Helper function to merge trigger characters from multiple languages + * @param languages Array of language identifiers to merge trigger characters from + * @returns Array of unique trigger characters from all specified languages + */ +function mergeTriggerCharacters(languages: string[]): string[] { + const triggerChars = new Set(); + languages.forEach(lang => { + if (LANGUAGE_MODES[lang]) { + LANGUAGE_MODES[lang].forEach(char => triggerChars.add(char)); + } + }); + return Array.from(triggerChars); +} + function refreshCompletionProviders(_: vscode.ExtensionContext) { clearCompletionProviderInfo(); @@ -195,19 +210,27 @@ function refreshCompletionProviders(_: vscode.ExtensionContext) { const useInlineCompletionProvider = vscode.workspace.getConfiguration('emmet').get('useInlineCompletions'); const includedLanguages = getMappingForIncludedLanguages(); Object.keys(includedLanguages).forEach(language => { - if (languageMappingForCompletionProviders.has(language) && languageMappingForCompletionProviders.get(language) === includedLanguages[language]) { - return; - } + const mapping = includedLanguages[language]; if (useInlineCompletionProvider) { const inlineCompletionsProvider = vscode.languages.registerInlineCompletionItemProvider({ language, scheme: '*' }, inlineCompletionProvider); completionProviderDisposables.push(inlineCompletionsProvider); } - const explicitProvider = vscode.languages.registerCompletionItemProvider({ language, scheme: '*' }, completionProvider, ...LANGUAGE_MODES[includedLanguages[language]]); + // Handle both single string and array of languages + let triggerChars: string[]; + if (typeof mapping === 'string') { + triggerChars = LANGUAGE_MODES[mapping] || []; + } else if (Array.isArray(mapping)) { + triggerChars = mergeTriggerCharacters(mapping); + } else { + triggerChars = []; + } + + const explicitProvider = vscode.languages.registerCompletionItemProvider({ language, scheme: '*' }, completionProvider, ...triggerChars); completionProviderDisposables.push(explicitProvider); - languageMappingForCompletionProviders.set(language, includedLanguages[language]); + languageMappingForCompletionProviders.set(language, mapping); }); Object.keys(LANGUAGE_MODES).forEach(language => { diff --git a/extensions/emmet/src/test/abbreviationAction.test.ts b/extensions/emmet/src/test/abbreviationAction.test.ts index 17ccacfc94a52f..71fe108417b639 100644 --- a/extensions/emmet/src/test/abbreviationAction.test.ts +++ b/extensions/emmet/src/test/abbreviationAction.test.ts @@ -417,6 +417,47 @@ suite('Tests for Expand Abbreviations (HTML)', () => { await workspace.getConfiguration('emmet').update('excludeLanguages', oldConfig, ConfigurationTarget.Global); }); + test('Expand html when inside script tag with javascript type if js is mapped to array with html (HTML)', async () => { + const oldConfig = workspace.getConfiguration('emmet').inspect('includeLanguages')?.globalValue; + await workspace.getConfiguration('emmet').update('includeLanguages', { 'javascript': ['html', 'css'] }, ConfigurationTarget.Global); + await withRandomFileEditor(htmlContents, 'html', async (editor, _doc) => { + editor.selection = new Selection(24, 10, 24, 10); + const expandPromise = expandEmmetAbbreviation(null); + if (!expandPromise) { + return Promise.resolve(); + } + await expandPromise; + assert.strictEqual(editor.document.getText(), htmlContents.replace('span.bye', '')); + }); + await workspace.getConfiguration('emmet').update('includeLanguages', oldConfig, ConfigurationTarget.Global); + }); + + test('Expand html in completion list when inside script tag with javascript type if js is mapped to array with html (HTML)', async () => { + const abbreviation = 'span.bye'; + const expandedText = ''; + const oldConfig = workspace.getConfiguration('emmet').inspect('includeLanguages')?.globalValue; + await workspace.getConfiguration('emmet').update('includeLanguages', { 'javascript': ['html', 'css'] }, ConfigurationTarget.Global); + await withRandomFileEditor(htmlContents, 'html', async (editor, _doc) => { + editor.selection = new Selection(24, 10, 24, 10); + const cancelSrc = new CancellationTokenSource(); + const completionPromise = completionProvider.provideCompletionItems(editor.document, editor.selection.active, cancelSrc.token, invokeCompletionContext); + if (!completionPromise) { + assert.strictEqual(1, 2, `Problem with expanding span.bye`); + return Promise.resolve(); + } + const completionList = await completionPromise; + if (!completionList || !completionList.items || !completionList.items.length) { + assert.strictEqual(1, 2, `Problem with expanding span.bye`); + return Promise.resolve(); + } + const emmetCompletionItem = completionList.items[0]; + assert.strictEqual(emmetCompletionItem.label, abbreviation, `Label of completion item (${emmetCompletionItem.label}) doesnt match.`); + assert.strictEqual(((emmetCompletionItem.documentation) || '').replace(/\|/g, ''), expandedText, `Docs of completion item doesnt match.`); + return Promise.resolve(); + }); + await workspace.getConfiguration('emmet').update('includeLanguages', oldConfig, ConfigurationTarget.Global); + }); + // test('No expanding when php (mapped syntax) is excluded in the settings', () => { // return workspace.getConfiguration('emmet').update('excludeLanguages', ['php'], ConfigurationTarget.Global).then(() => { // return testExpandAbbreviation('php', new Selection(9, 6, 9, 6), '', '', true).then(() => { diff --git a/extensions/emmet/src/test/completion.test.ts b/extensions/emmet/src/test/completion.test.ts index bf61f338f21c79..7d05da65ccd069 100644 --- a/extensions/emmet/src/test/completion.test.ts +++ b/extensions/emmet/src/test/completion.test.ts @@ -5,7 +5,7 @@ import * as assert from 'assert'; import 'mocha'; -import { CancellationTokenSource, CompletionTriggerKind, Selection } from 'vscode'; +import { CancellationTokenSource, CompletionTriggerKind, Selection, workspace, ConfigurationTarget } from 'vscode'; import { DefaultCompletionItemProvider } from '../defaultCompletionProvider'; import { closeAllEditors, withRandomFileEditor } from './testUtils'; @@ -86,6 +86,13 @@ interface TestCompletionItem { documentation?: string; } +/** + * Tests the completion provider for a given file extension and content + * @param fileExtension The file extension to test + * @param contents The file content with cursor position marked by '|' + * @param expectedItems Expected completion items or undefined if no completions expected + * @returns A promise that resolves when the test is complete + */ function testCompletionProvider(fileExtension: string, contents: string, expectedItems: TestCompletionItem[] | undefined): Thenable { const cursorPos = contents.indexOf('|'); const slicedContents = contents.slice(0, cursorPos) + contents.slice(cursorPos + 1); @@ -130,3 +137,70 @@ function testCompletionProvider(fileExtension: string, contents: string, expecte return Promise.resolve(); }); } + +suite('Tests for one-to-many language mapping', () => { + teardown(closeAllEditors); + + test('Array mapping with HTML and CSS should provide HTML completions', async () => { + const oldConfig = workspace.getConfiguration('emmet').inspect('includeLanguages')?.globalValue; + await workspace.getConfiguration('emmet').update('includeLanguages', { 'javascript': ['html', 'css'] }, ConfigurationTarget.Global); + + try { + await testCompletionProvider('javascript', '
|
` } + ]); + } finally { + await workspace.getConfiguration('emmet').update('includeLanguages', oldConfig, ConfigurationTarget.Global); + } + }); + + test('Array mapping with HTML and CSS should provide CSS completions in style context', async () => { + const oldConfig = workspace.getConfiguration('emmet').inspect('includeLanguages')?.globalValue; + await workspace.getConfiguration('emmet').update('includeLanguages', { 'javascript': ['html', 'css'] }, ConfigurationTarget.Global); + + try { + await testCompletionProvider('javascript', '
', [ + { label: 'padding: ;', documentation: `padding: |;` } + ]); + } finally { + await workspace.getConfiguration('emmet').update('includeLanguages', oldConfig, ConfigurationTarget.Global); + } + }); + + test('Array mapping with invalid languages should filter to valid ones', async () => { + const oldConfig = workspace.getConfiguration('emmet').inspect('includeLanguages')?.globalValue; + await workspace.getConfiguration('emmet').update('includeLanguages', { 'javascript': ['html', 'invalidlang', 'css'] }, ConfigurationTarget.Global); + + try { + await testCompletionProvider('javascript', '
|
` } + ]); + } finally { + await workspace.getConfiguration('emmet').update('includeLanguages', oldConfig, ConfigurationTarget.Global); + } + }); + + test('Backward compatibility: single string mapping still works', async () => { + const oldConfig = workspace.getConfiguration('emmet').inspect('includeLanguages')?.globalValue; + await workspace.getConfiguration('emmet').update('includeLanguages', { 'javascript': 'html' }, ConfigurationTarget.Global); + + try { + await testCompletionProvider('javascript', '
|
` } + ]); + } finally { + await workspace.getConfiguration('emmet').update('includeLanguages', oldConfig, ConfigurationTarget.Global); + } + }); + + test('Empty array should not register any completions', async () => { + const oldConfig = workspace.getConfiguration('emmet').inspect('includeLanguages')?.globalValue; + await workspace.getConfiguration('emmet').update('includeLanguages', { 'javascript': [] }, ConfigurationTarget.Global); + + try { + await testCompletionProvider('javascript', '
{ + + test('Should return built-in mappings when no config is set', async () => { + const oldConfig = vscode.workspace.getConfiguration('emmet').inspect('includeLanguages')?.globalValue; + await vscode.workspace.getConfiguration('emmet').update('includeLanguages', undefined, vscode.ConfigurationTarget.Global); + + try { + const mapping = getMappingForIncludedLanguages(); + assert.ok(mapping); + // getMappingForIncludedLanguages includes built-in default mappings + // (e.g., handlebars: 'html', php: 'html') even when user config is unset + assert.ok(Object.keys(mapping).length > 0); + assert.strictEqual(mapping['handlebars'], 'html'); + assert.strictEqual(mapping['php'], 'html'); + } finally { + await vscode.workspace.getConfiguration('emmet').update('includeLanguages', oldConfig, vscode.ConfigurationTarget.Global); + } + }); + + test('Should handle single string mapping (backward compatibility)', async () => { + const oldConfig = vscode.workspace.getConfiguration('emmet').inspect('includeLanguages')?.globalValue; + await vscode.workspace.getConfiguration('emmet').update('includeLanguages', { 'javascript': 'html' }, vscode.ConfigurationTarget.Global); + + try { + const mapping = getMappingForIncludedLanguages(); + assert.strictEqual(mapping['javascript'], 'html'); + } finally { + await vscode.workspace.getConfiguration('emmet').update('includeLanguages', oldConfig, vscode.ConfigurationTarget.Global); + } + }); + + test('Should handle array of languages mapping', async () => { + const oldConfig = vscode.workspace.getConfiguration('emmet').inspect('includeLanguages')?.globalValue; + await vscode.workspace.getConfiguration('emmet').update('includeLanguages', { 'javascript': ['html', 'css', 'javascript'] }, vscode.ConfigurationTarget.Global); + + try { + const mapping = getMappingForIncludedLanguages(); + assert.ok(Array.isArray(mapping['javascript'])); + assert.strictEqual((mapping['javascript'] as string[]).length, 3); + assert.ok((mapping['javascript'] as string[]).includes('html')); + assert.ok((mapping['javascript'] as string[]).includes('css')); + assert.ok((mapping['javascript'] as string[]).includes('javascript')); + } finally { + await vscode.workspace.getConfiguration('emmet').update('includeLanguages', oldConfig, vscode.ConfigurationTarget.Global); + } + }); + + test('Should filter out invalid languages from array', async () => { + const oldConfig = vscode.workspace.getConfiguration('emmet').inspect('includeLanguages')?.globalValue; + await vscode.workspace.getConfiguration('emmet').update('includeLanguages', { 'javascript': ['html', 'invalidlang', 'css'] }, vscode.ConfigurationTarget.Global); + + try { + const mapping = getMappingForIncludedLanguages(); + assert.ok(Array.isArray(mapping['javascript'])); + assert.strictEqual((mapping['javascript'] as string[]).length, 2); + assert.ok((mapping['javascript'] as string[]).includes('html')); + assert.ok((mapping['javascript'] as string[]).includes('css')); + assert.ok(!(mapping['javascript'] as string[]).includes('invalidlang')); + } finally { + await vscode.workspace.getConfiguration('emmet').update('includeLanguages', oldConfig, vscode.ConfigurationTarget.Global); + } + }); + + test('Should handle empty array', async () => { + const oldConfig = vscode.workspace.getConfiguration('emmet').inspect('includeLanguages')?.globalValue; + await vscode.workspace.getConfiguration('emmet').update('includeLanguages', { 'javascript': [] }, vscode.ConfigurationTarget.Global); + + try { + const mapping = getMappingForIncludedLanguages(); + assert.ok(!mapping['javascript']); + } finally { + await vscode.workspace.getConfiguration('emmet').update('includeLanguages', oldConfig, vscode.ConfigurationTarget.Global); + } + }); + + test('Should handle mixed string and array mappings', async () => { + const oldConfig = vscode.workspace.getConfiguration('emmet').inspect('includeLanguages')?.globalValue; + await vscode.workspace.getConfiguration('emmet').update('includeLanguages', { + 'javascript': ['html', 'css'], + 'typescript': 'html' + }, vscode.ConfigurationTarget.Global); + + try { + const mapping = getMappingForIncludedLanguages(); + assert.ok(Array.isArray(mapping['javascript'])); + assert.strictEqual((mapping['javascript'] as string[]).length, 2); + assert.strictEqual(mapping['typescript'], 'html'); + } finally { + await vscode.workspace.getConfiguration('emmet').update('includeLanguages', oldConfig, vscode.ConfigurationTarget.Global); + } + }); + + test('Should filter invalid string mappings', async () => { + const oldConfig = vscode.workspace.getConfiguration('emmet').inspect('includeLanguages')?.globalValue; + await vscode.workspace.getConfiguration('emmet').update('includeLanguages', { 'javascript': 'invalidlang' }, vscode.ConfigurationTarget.Global); + + try { + const mapping = getMappingForIncludedLanguages(); + assert.ok(!mapping['javascript']); + } finally { + await vscode.workspace.getConfiguration('emmet').update('includeLanguages', oldConfig, vscode.ConfigurationTarget.Global); + } + }); +}); \ No newline at end of file diff --git a/extensions/emmet/src/util.ts b/extensions/emmet/src/util.ts index e934df84e71336..cf94847c44945a 100644 --- a/extensions/emmet/src/util.ts +++ b/extensions/emmet/src/util.ts @@ -116,7 +116,12 @@ export function validate(allowStylesheet: boolean = true): boolean { return true; } -export function getMappingForIncludedLanguages(): Record { +/** + * Gets the mapping of languages to their parent Emmet languages + * Supports both single string mappings and array mappings for one-to-many relationships + * @returns Record mapping language identifiers to their target Emmet language(s) + */ +export function getMappingForIncludedLanguages(): Record { // Explicitly map languages that have built-in grammar in VS Code to their parent language // to get emmet completion support // For other languages, users will have to use `emmet.includeLanguages` or @@ -126,12 +131,22 @@ export function getMappingForIncludedLanguages(): Record { 'php': 'html' }; - const finalMappedModes: Record = {}; - const includeLanguagesConfig = vscode.workspace.getConfiguration('emmet').get>('includeLanguages'); - const includeLanguages = Object.assign({}, MAPPED_MODES, includeLanguagesConfig ?? {}); + const finalMappedModes: Record = {}; + const includeLanguagesConfig = vscode.workspace.getConfiguration('emmet').get>('includeLanguages'); + const includeLanguages: Record = Object.assign({}, MAPPED_MODES, includeLanguagesConfig ?? {}); Object.keys(includeLanguages).forEach(syntax => { - if (typeof includeLanguages[syntax] === 'string' && LANGUAGE_MODES[includeLanguages[syntax]]) { - finalMappedModes[syntax] = includeLanguages[syntax]; + const value = includeLanguages[syntax]; + if (typeof value === 'string') { + // Handle single string mapping (backward compatibility) + if (LANGUAGE_MODES[value]) { + finalMappedModes[syntax] = value; + } + } else if (Array.isArray(value)) { + // Handle array of languages (new feature) + const validLanguages = value.filter((lang): lang is string => typeof lang === 'string' && !!LANGUAGE_MODES[lang]); + if (validLanguages.length > 0) { + finalMappedModes[syntax] = validLanguages; + } } }); return finalMappedModes; @@ -145,7 +160,14 @@ export function getMappingForIncludedLanguages(): Record { * * @param excludedLanguages Array of language ids that user has chosen to exclude for emmet */ -export function getEmmetMode(language: string, mappedModes: Record, excludedLanguages: string[]): string | undefined { +/** + * Gets the Emmet mode for a given language + * @param language The language identifier to get the Emmet mode for + * @param mappedModes Mapping of languages to their target Emmet language(s) + * @param excludedLanguages Array of excluded language identifiers + * @returns The Emmet mode string or undefined if not applicable + */ +export function getEmmetMode(language: string, mappedModes: Record, excludedLanguages: string[]): string | undefined { if (!language || excludedLanguages.includes(language)) { return; } @@ -155,16 +177,21 @@ export function getEmmetMode(language: string, mappedModes: Record