Commit 04e38af4 authored by Himanshu Kapoor's avatar Himanshu Kapoor Committed by Paul Slaughter

Add support for .editorconfig in Web IDE

Developers collaborating on projects follow certain coding standards
styles to maintain consistent levels of readability and format. These
standards are usually provided to developers through configuration
files that the developer's editor might read and apply. When working in
the Web IDE, even if a project has configured this type of file, those
settings can't be enforced.

The leader in standards for this is EditorConfig which providers a file
format for defining coding styles. This change adds support for reading
.editorconfig files in the Web IDE and applying configuration settings
automatically.
parent 62329379
......@@ -15,6 +15,8 @@ import FileTemplatesBar from './file_templates/bar.vue';
import { __ } from '~/locale';
import { extractMarkdownImagesFromEntries } from '../stores/utils';
import { getPathParent, readFileAsDataURL } from '../utils';
import { getRulesWithTraversal } from '../lib/editorconfig/parser';
import mapRulesToMonaco from '../lib/editorconfig/rules_mapper';
export default {
components: {
......@@ -32,6 +34,7 @@ export default {
return {
content: '',
images: {},
rules: {},
};
},
computed: {
......@@ -195,7 +198,7 @@ export default {
this.editor.clearEditor();
this.fetchFileData()
Promise.all([this.fetchFileData(), this.fetchEditorconfigRules()])
.then(() => {
this.createEditorInstance();
})
......@@ -254,6 +257,8 @@ export default {
this.editor.attachModel(this.model);
}
this.model.updateOptions(this.rules);
this.model.onChange(model => {
const { file } = model;
if (!file.active) return;
......@@ -280,12 +285,29 @@ export default {
this.setFileLanguage({
fileLanguage: this.model.language,
});
this.$emit('editorSetup');
},
refreshEditorDimensions() {
if (this.showEditor) {
this.editor.updateDimensions();
}
},
fetchEditorconfigRules() {
return getRulesWithTraversal(this.file.path, path => {
const entry = this.entries[path];
if (!entry) return Promise.resolve(null);
const content = entry.content || entry.raw;
if (content) return Promise.resolve(content);
return this.getFileData({ path: entry.path, makeFileActive: false }).then(() =>
this.getRawFileData({ path: entry.path }),
);
}).then(rules => {
this.rules = mapRulesToMonaco(rules);
});
},
onPaste(event) {
const editor = this.editor.instance;
const reImage = /^image\/(png|jpg|jpeg|gif)$/;
......
import { parseString } from 'editorconfig/src/lib/ini';
import minimatch from 'minimatch';
import { getPathParents } from '../../utils';
const dirname = path => path.replace(/\.editorconfig$/, '');
function isRootConfig(config) {
return config.some(([pattern, rules]) => !pattern && rules?.root === 'true');
}
function getRulesForSection(path, [pattern, rules]) {
if (!pattern) {
return {};
}
if (minimatch(path, pattern, { matchBase: true })) {
return rules;
}
return {};
}
function getRulesWithConfigs(filePath, configFiles = [], rules = {}) {
if (!configFiles.length) return rules;
const [{ content, path: configPath }, ...nextConfigs] = configFiles;
const configDir = dirname(configPath);
if (!filePath.startsWith(configDir)) return rules;
const parsed = parseString(content);
const isRoot = isRootConfig(parsed);
const relativeFilePath = filePath.slice(configDir.length);
const sectionRules = parsed.reduce(
(acc, section) => Object.assign(acc, getRulesForSection(relativeFilePath, section)),
{},
);
// prefer existing rules by overwriting to section rules
const result = Object.assign(sectionRules, rules);
return isRoot ? result : getRulesWithConfigs(filePath, nextConfigs, result);
}
// eslint-disable-next-line import/prefer-default-export
export function getRulesWithTraversal(filePath, getFileContent) {
const editorconfigPaths = [
...getPathParents(filePath).map(x => `${x}/.editorconfig`),
'.editorconfig',
];
return Promise.all(
editorconfigPaths.map(path => getFileContent(path).then(content => ({ path, content }))),
).then(results => getRulesWithConfigs(filePath, results.filter(x => x.content)));
}
import { isBoolean, isNumber } from 'lodash';
const map = (key, validValues) => value =>
value in validValues ? { [key]: validValues[value] } : {};
const bool = key => value => (isBoolean(value) ? { [key]: value } : {});
const int = (key, isValid) => value =>
isNumber(value) && isValid(value) ? { [key]: Math.trunc(value) } : {};
const rulesMapper = {
indent_style: map('insertSpaces', { tab: false, space: true }),
indent_size: int('tabSize', n => n > 0),
tab_width: int('tabSize', n => n > 0),
trim_trailing_whitespace: bool('trimTrailingWhitespace'),
end_of_line: map('endOfLine', { crlf: 1, lf: 0 }),
insert_final_newline: bool('insertFinalNewline'),
};
const parseValue = x => {
let value = typeof x === 'string' ? x.toLowerCase() : x;
if (/^[0-9.-]+$/.test(value)) value = Number(value);
if (value === 'true') value = true;
if (value === 'false') value = false;
return value;
};
export default function mapRulesToMonaco(rules) {
return Object.entries(rules).reduce((obj, [key, value]) => {
return Object.assign(obj, rulesMapper[key]?.(parseValue(value)) || {});
}, {});
}
......@@ -15,8 +15,7 @@ import routerModule from './modules/router';
Vue.use(Vuex);
export const createStore = () =>
new Vuex.Store({
export const createStoreOptions = () => ({
state: state(),
actions,
mutations,
......@@ -31,6 +30,8 @@ export const createStore = () =>
clientside: clientsideModule(),
router: routerModule,
},
});
});
export const createStore = () => new Vuex.Store(createStoreOptions());
export default createStore();
---
title: Support reading .editorconfig files inside of the Web IDE
merge_request: 32378
author:
type: added
......@@ -9,5 +9,8 @@ import 'monaco-editor/esm/vs/language/json/monaco.contribution';
import 'monaco-editor/esm/vs/language/html/monaco.contribution';
import 'monaco-editor/esm/vs/basic-languages/monaco.contribution';
// This language starts trying to spin up web workers which obviously breaks in Jest environment
jest.mock('monaco-editor/esm/vs/language/typescript/tsMode');
export * from 'monaco-editor/esm/vs/editor/editor.api';
export default global.monaco;
import Vuex from 'vuex';
import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter';
import '~/behaviors/markdown/render_gfm';
import { Range } from 'monaco-editor';
import axios from '~/lib/utils/axios_utils';
import { createStore } from '~/ide/stores';
import repoEditor from '~/ide/components/repo_editor.vue';
import { createStoreOptions } from '~/ide/stores';
import RepoEditor from '~/ide/components/repo_editor.vue';
import Editor from '~/ide/lib/editor';
import { leftSidebarViews, FILE_VIEW_MODE_EDITOR, FILE_VIEW_MODE_PREVIEW } from '~/ide/constants';
import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { file } from '../helpers';
import { exampleConfigs, exampleFiles } from '../lib/editorconfig/mock_data';
describe('RepoEditor', () => {
let vm;
let store;
let mockActions;
const waitForEditorSetup = () =>
new Promise(resolve => {
vm.$once('editorSetup', resolve);
});
const createComponent = () => {
if (vm) {
throw new Error('vm already exists');
}
vm = createComponentWithStore(Vue.extend(RepoEditor), store, {
file: store.state.openFiles[0],
});
vm.$mount();
};
const createOpenFile = path => {
const origFile = store.state.openFiles[0];
const newFile = { ...origFile, path, key: path };
store.state.entries[path] = newFile;
store.state.openFiles = [newFile];
};
beforeEach(() => {
mockActions = {
getFileData: jest.fn().mockResolvedValue(),
getRawFileData: jest.fn().mockResolvedValue(),
};
const f = {
...file(),
viewMode: FILE_VIEW_MODE_EDITOR,
};
const RepoEditor = Vue.extend(repoEditor);
store = createStore();
vm = createComponentWithStore(RepoEditor, store, {
file: f,
});
const storeOptions = createStoreOptions();
storeOptions.actions = {
...storeOptions.actions,
...mockActions,
};
store = new Vuex.Store(storeOptions);
f.active = true;
f.tempFile = true;
vm.$store.state.openFiles.push(f);
vm.$store.state.projects = {
store.state.openFiles.push(f);
store.state.projects = {
'gitlab-org/gitlab': {
branches: {
master: {
......@@ -43,27 +76,28 @@ describe('RepoEditor', () => {
},
},
};
vm.$store.state.currentProjectId = 'gitlab-org/gitlab';
vm.$store.state.currentBranchId = 'master';
store.state.currentProjectId = 'gitlab-org/gitlab';
store.state.currentBranchId = 'master';
Vue.set(vm.$store.state.entries, f.path, f);
jest.spyOn(vm, 'getFileData').mockResolvedValue();
jest.spyOn(vm, 'getRawFileData').mockResolvedValue();
vm.$mount();
return vm.$nextTick();
Vue.set(store.state.entries, f.path, f);
});
afterEach(() => {
vm.$destroy();
vm = null;
Editor.editorInstance.dispose();
});
const findEditor = () => vm.$el.querySelector('.multi-file-editor-holder');
describe('default', () => {
beforeEach(() => {
createComponent();
return waitForEditorSetup();
});
it('sets renderWhitespace to `all`', () => {
vm.$store.state.renderWhitespaceInCode = true;
......@@ -98,6 +132,7 @@ describe('RepoEditor', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
mock.onPost(/(.*)\/preview_markdown/).reply(200, {
body: '<p>testing 123</p>',
});
......@@ -274,11 +309,11 @@ describe('RepoEditor', () => {
});
it('updates state with the value of the model', () => {
vm.model.setValue('testing 1234');
vm.model.setValue('testing 1234\n');
vm.setupEditor();
expect(vm.file.content).toBe('testing 1234');
expect(vm.file.content).toBe('testing 1234\n');
});
it('sets head model as staged file', () => {
......@@ -403,7 +438,7 @@ describe('RepoEditor', () => {
vm.initEditor();
vm.$nextTick()
.then(() => {
expect(vm.getFileData).not.toHaveBeenCalled();
expect(mockActions.getFileData).not.toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
......@@ -416,8 +451,8 @@ describe('RepoEditor', () => {
vm.initEditor();
vm.$nextTick()
.then(() => {
expect(vm.getFileData).toHaveBeenCalled();
expect(vm.getRawFileData).toHaveBeenCalled();
expect(mockActions.getFileData).toHaveBeenCalled();
expect(mockActions.getRawFileData).toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
......@@ -429,8 +464,8 @@ describe('RepoEditor', () => {
vm.initEditor();
vm.$nextTick()
.then(() => {
expect(vm.getFileData).not.toHaveBeenCalled();
expect(vm.getRawFileData).not.toHaveBeenCalled();
expect(mockActions.getFileData).not.toHaveBeenCalled();
expect(mockActions.getRawFileData).not.toHaveBeenCalled();
expect(vm.editor.createInstance).not.toHaveBeenCalled();
})
.then(done)
......@@ -576,4 +611,54 @@ describe('RepoEditor', () => {
});
});
});
});
describe('fetchEditorconfigRules', () => {
beforeEach(() => {
exampleConfigs.forEach(({ path, content }) => {
store.state.entries[path] = { ...file(), path, content };
});
});
it.each(exampleFiles)(
'does not fetch content from remote for .editorconfig files present locally (case %#)',
({ path, monacoRules }) => {
createOpenFile(path);
createComponent();
return waitForEditorSetup().then(() => {
expect(vm.rules).toEqual(monacoRules);
expect(vm.model.options).toMatchObject(monacoRules);
expect(mockActions.getFileData).not.toHaveBeenCalled();
expect(mockActions.getRawFileData).not.toHaveBeenCalled();
});
},
);
it('fetches content from remote for .editorconfig files not available locally', () => {
exampleConfigs.forEach(({ path }) => {
delete store.state.entries[path].content;
delete store.state.entries[path].raw;
});
// Include a "test" directory which does not exist in store. This one should be skipped.
createOpenFile('foo/bar/baz/test/my_spec.js');
createComponent();
return waitForEditorSetup().then(() => {
expect(mockActions.getFileData.mock.calls.map(([, args]) => args)).toEqual([
{ makeFileActive: false, path: 'foo/bar/baz/.editorconfig' },
{ makeFileActive: false, path: 'foo/bar/.editorconfig' },
{ makeFileActive: false, path: 'foo/.editorconfig' },
{ makeFileActive: false, path: '.editorconfig' },
]);
expect(mockActions.getRawFileData.mock.calls.map(([, args]) => args)).toEqual([
{ path: 'foo/bar/baz/.editorconfig' },
{ path: 'foo/bar/.editorconfig' },
{ path: 'foo/.editorconfig' },
{ path: '.editorconfig' },
]);
});
});
});
});
export const exampleConfigs = [
{
path: 'foo/bar/baz/.editorconfig',
content: `
[*]
tab_width = 6
indent_style = tab
`,
},
{
path: 'foo/bar/.editorconfig',
content: `
root = false
[*]
indent_size = 5
indent_style = space
trim_trailing_whitespace = true
[*_spec.{js,py}]
end_of_line = crlf
`,
},
{
path: 'foo/.editorconfig',
content: `
[*]
tab_width = 4
indent_style = tab
`,
},
{
path: '.editorconfig',
content: `
root = true
[*]
indent_size = 3
indent_style = space
end_of_line = lf
insert_final_newline = true
[*.js]
indent_size = 2
indent_style = space
trim_trailing_whitespace = true
[*.txt]
end_of_line = crlf
`,
},
{
path: 'foo/bar/root/.editorconfig',
content: `
root = true
[*]
tab_width = 1
indent_style = tab
`,
},
];
export const exampleFiles = [
{
path: 'foo/bar/root/README.md',
rules: {
indent_style: 'tab', // foo/bar/root/.editorconfig
tab_width: '1', // foo/bar/root/.editorconfig
},
monacoRules: {
insertSpaces: false,
tabSize: 1,
},
},
{
path: 'foo/bar/baz/my_spec.js',
rules: {
end_of_line: 'crlf', // foo/bar/.editorconfig (for _spec.js files)
indent_size: '5', // foo/bar/.editorconfig
indent_style: 'tab', // foo/bar/baz/.editorconfig
insert_final_newline: 'true', // .editorconfig
tab_width: '6', // foo/bar/baz/.editorconfig
trim_trailing_whitespace: 'true', // .editorconfig (for .js files)
},
monacoRules: {
endOfLine: 1,
insertFinalNewline: true,
insertSpaces: false,
tabSize: 6,
trimTrailingWhitespace: true,
},
},
{
path: 'foo/my_file.js',
rules: {
end_of_line: 'lf', // .editorconfig
indent_size: '2', // .editorconfig (for .js files)
indent_style: 'tab', // foo/.editorconfig
insert_final_newline: 'true', // .editorconfig
tab_width: '4', // foo/.editorconfig
trim_trailing_whitespace: 'true', // .editorconfig (for .js files)
},
monacoRules: {
endOfLine: 0,
insertFinalNewline: true,
insertSpaces: false,
tabSize: 4,
trimTrailingWhitespace: true,
},
},
{
path: 'foo/my_file.md',
rules: {
end_of_line: 'lf', // .editorconfig
indent_size: '3', // .editorconfig
indent_style: 'tab', // foo/.editorconfig
insert_final_newline: 'true', // .editorconfig
tab_width: '4', // foo/.editorconfig
},
monacoRules: {
endOfLine: 0,
insertFinalNewline: true,
insertSpaces: false,
tabSize: 4,
},
},
{
path: 'foo/bar/my_file.txt',
rules: {
end_of_line: 'crlf', // .editorconfig (for .txt files)
indent_size: '5', // foo/bar/.editorconfig
indent_style: 'space', // foo/bar/.editorconfig
insert_final_newline: 'true', // .editorconfig
tab_width: '4', // foo/.editorconfig
trim_trailing_whitespace: 'true', // foo/bar/.editorconfig
},
monacoRules: {
endOfLine: 1,
insertFinalNewline: true,
insertSpaces: true,
tabSize: 4,
trimTrailingWhitespace: true,
},
},
];
import { getRulesWithTraversal } from '~/ide/lib/editorconfig/parser';
import { exampleConfigs, exampleFiles } from './mock_data';
describe('~/ide/lib/editorconfig/parser', () => {
const getExampleConfigContent = path =>
Promise.resolve(exampleConfigs.find(x => x.path === path)?.content);
describe('getRulesWithTraversal', () => {
it.each(exampleFiles)(
'traverses through all editorconfig files in parent directories (until root=true is hit) and finds rules for this file (case %#)',
({ path, rules }) => {
return getRulesWithTraversal(path, getExampleConfigContent).then(result => {
expect(result).toEqual(rules);
});
},
);
});
});
import mapRulesToMonaco from '~/ide/lib/editorconfig/rules_mapper';
describe('mapRulesToMonaco', () => {
const multipleEntries = {
input: { indent_style: 'tab', indent_size: '4', insert_final_newline: 'true' },
output: { insertSpaces: false, tabSize: 4, insertFinalNewline: true },
};
// tab width takes precedence
const tabWidthAndIndent = {
input: { indent_style: 'tab', indent_size: '4', tab_width: '3' },
output: { insertSpaces: false, tabSize: 3 },
};
it.each`
rule | monacoOption
${{ indent_style: 'tab' }} | ${{ insertSpaces: false }}
${{ indent_style: 'space' }} | ${{ insertSpaces: true }}
${{ indent_style: 'unset' }} | ${{}}
${{ indent_size: '4' }} | ${{ tabSize: 4 }}
${{ indent_size: '4.4' }} | ${{ tabSize: 4 }}
${{ indent_size: '0' }} | ${{}}
${{ indent_size: '-10' }} | ${{}}
${{ indent_size: 'NaN' }} | ${{}}
${{ tab_width: '4' }} | ${{ tabSize: 4 }}
${{ tab_width: '5.4' }} | ${{ tabSize: 5 }}
${{ tab_width: '-10' }} | ${{}}
${{ trim_trailing_whitespace: 'true' }} | ${{ trimTrailingWhitespace: true }}
${{ trim_trailing_whitespace: 'false' }} | ${{ trimTrailingWhitespace: false }}
${{ trim_trailing_whitespace: 'unset' }} | ${{}}
${{ end_of_line: 'lf' }} | ${{ endOfLine: 0 }}
${{ end_of_line: 'crlf' }} | ${{ endOfLine: 1 }}
${{ end_of_line: 'cr' }} | ${{}}
${{ end_of_line: 'unset' }} | ${{}}
${{ insert_final_newline: 'true' }} | ${{ insertFinalNewline: true }}
${{ insert_final_newline: 'false' }} | ${{ insertFinalNewline: false }}
${{ insert_final_newline: 'unset' }} | ${{}}
${multipleEntries.input} | ${multipleEntries.output}
${tabWidthAndIndent.input} | ${tabWidthAndIndent.output}
`('correctly maps editorconfig rule to monaco option: $rule', ({ rule, monacoOption }) => {
expect(mapRulesToMonaco(rule)).toEqual(monacoOption);
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment