Commit 0dcc35a9 authored by Paul Slaughter's avatar Paul Slaughter

Merge branch '23352-editorconfig' into 'master'

Support EditorConfig inside of the Web IDE

See merge request gitlab-org/gitlab!32378
parents f88f40ee 04e38af4
...@@ -15,6 +15,8 @@ import FileTemplatesBar from './file_templates/bar.vue'; ...@@ -15,6 +15,8 @@ import FileTemplatesBar from './file_templates/bar.vue';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { extractMarkdownImagesFromEntries } from '../stores/utils'; import { extractMarkdownImagesFromEntries } from '../stores/utils';
import { getPathParent, readFileAsDataURL } from '../utils'; import { getPathParent, readFileAsDataURL } from '../utils';
import { getRulesWithTraversal } from '../lib/editorconfig/parser';
import mapRulesToMonaco from '../lib/editorconfig/rules_mapper';
export default { export default {
components: { components: {
...@@ -32,6 +34,7 @@ export default { ...@@ -32,6 +34,7 @@ export default {
return { return {
content: '', content: '',
images: {}, images: {},
rules: {},
}; };
}, },
computed: { computed: {
...@@ -195,7 +198,7 @@ export default { ...@@ -195,7 +198,7 @@ export default {
this.editor.clearEditor(); this.editor.clearEditor();
this.fetchFileData() Promise.all([this.fetchFileData(), this.fetchEditorconfigRules()])
.then(() => { .then(() => {
this.createEditorInstance(); this.createEditorInstance();
}) })
...@@ -254,6 +257,8 @@ export default { ...@@ -254,6 +257,8 @@ export default {
this.editor.attachModel(this.model); this.editor.attachModel(this.model);
} }
this.model.updateOptions(this.rules);
this.model.onChange(model => { this.model.onChange(model => {
const { file } = model; const { file } = model;
if (!file.active) return; if (!file.active) return;
...@@ -280,12 +285,29 @@ export default { ...@@ -280,12 +285,29 @@ export default {
this.setFileLanguage({ this.setFileLanguage({
fileLanguage: this.model.language, fileLanguage: this.model.language,
}); });
this.$emit('editorSetup');
}, },
refreshEditorDimensions() { refreshEditorDimensions() {
if (this.showEditor) { if (this.showEditor) {
this.editor.updateDimensions(); 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) { onPaste(event) {
const editor = this.editor.instance; const editor = this.editor.instance;
const reImage = /^image\/(png|jpg|jpeg|gif)$/; 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,22 +15,23 @@ import routerModule from './modules/router'; ...@@ -15,22 +15,23 @@ import routerModule from './modules/router';
Vue.use(Vuex); Vue.use(Vuex);
export const createStore = () => export const createStoreOptions = () => ({
new Vuex.Store({ state: state(),
state: state(), actions,
actions, mutations,
mutations, getters,
getters, modules: {
modules: { commit: commitModule,
commit: commitModule, pipelines,
pipelines, mergeRequests,
mergeRequests, branches,
branches, fileTemplates: fileTemplates(),
fileTemplates: fileTemplates(), rightPane: paneModule(),
rightPane: paneModule(), clientside: clientsideModule(),
clientside: clientsideModule(), router: routerModule,
router: routerModule, },
}, });
});
export const createStore = () => new Vuex.Store(createStoreOptions());
export default createStore(); 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'; ...@@ -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/language/html/monaco.contribution';
import 'monaco-editor/esm/vs/basic-languages/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 * from 'monaco-editor/esm/vs/editor/editor.api';
export default global.monaco; export default global.monaco;
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