Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: flawed config handling in the FQBN #2113

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { inject, injectable } from '@theia/core/shared/inversify';
import { Mutex } from 'async-mutex';
import {
ArduinoDaemon,
assertSanitizedFqbn,
BoardsService,
ExecutableService,
sanitizeFqbn,
Expand Down Expand Up @@ -93,8 +92,10 @@ export class InoLanguage extends SketchContribution {
`Failed to sanitize the FQBN of the running language server. FQBN with the board settings was: ${this.languageServerFqbn}`
);
}
// The incoming FQBNs might contain custom boards configs, sanitize them before the comparison.
// https://github.com/arduino/arduino-ide/pull/2113#pullrequestreview-1499998328
const matchingFqbn = dataChangePerFqbn.find(
(fqbn) => sanitizedFqbn === fqbn
(fqbn) => sanitizedFqbn === sanitizeFqbn(fqbn)
);
const { boardsConfig } = this.boardsServiceProvider;
if (
Expand Down Expand Up @@ -151,7 +152,6 @@ export class InoLanguage extends SketchContribution {
}
return;
}
assertSanitizedFqbn(fqbn);
const fqbnWithConfig = await this.boardDataStore.appendConfigToFqbn(fqbn);
if (!fqbnWithConfig) {
throw new Error(
Expand Down
107 changes: 92 additions & 15 deletions arduino-ide-extension/src/common/protocol/boards-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ export namespace BoardSearch {
'Partner',
'Arduino@Heart',
] as const;
export type Type = typeof TypeLiterals[number];
export type Type = (typeof TypeLiterals)[number];
export namespace Type {
export function is(arg: unknown): arg is Type {
return typeof arg === 'string' && TypeLiterals.includes(arg as Type);
Expand Down Expand Up @@ -347,7 +347,7 @@ export namespace Port {

export namespace Protocols {
export const KnownProtocolLiterals = ['serial', 'network'] as const;
export type KnownProtocol = typeof KnownProtocolLiterals[number];
export type KnownProtocol = (typeof KnownProtocolLiterals)[number];
export namespace KnownProtocol {
export function is(protocol: unknown): protocol is KnownProtocol {
return (
Expand Down Expand Up @@ -476,29 +476,106 @@ export namespace ConfigOption {
fqbn: string,
configOptions: ConfigOption[]
): string {
if (!configOptions.length) {
return fqbn;
const failInvalidFQBN = (): never => {
throw new Error(`Invalid FQBN: ${fqbn}`);
};

const [vendor, arch, id, rest] = fqbn.split(':');
if (!vendor || !arch || !id) {
return failInvalidFQBN();
}

const toValue = (values: ConfigValue[]) => {
const existingOptions: Record<string, string> = {};
if (rest) {
// If rest exists, it must have the key=value(,key=value)* format. Otherwise, fail.
const tuples = rest.split(',');
for (const tuple of tuples) {
const segments = tuple.split('=');
if (segments.length !== 2) {
failInvalidFQBN();
}
const [option, value] = segments;
if (!option || !value) {
failInvalidFQBN();
}
if (existingOptions[option]) {
console.warn(
`Config value already exists for '${option}' on FQBN. Skipping it. Existing value: ${existingOptions[option]}, new value: ${value}, FQBN: ${fqbn}`
);
} else {
existingOptions[option] = value;
}
}
}

const newOptions: Record<string, string> = {};
for (const configOption of configOptions) {
const { option, values } = configOption;
if (!option) {
console.warn(
`Detected empty option on config options. Skipping it. ${JSON.stringify(
configOption
)}`
);
continue;
}
const selectedValue = values.find(({ selected }) => selected);
if (!selectedValue) {
if (selectedValue) {
const { value } = selectedValue;
if (!value) {
console.warn(
`Detected empty selected value on config options. Skipping it. ${JSON.stringify(
configOption
)}`
);
continue;
}
if (newOptions[option]) {
console.warn(
`Config value already exists for '${option}' in config options. Skipping it. Existing value: ${
newOptions[option]
}, new value: ${value}, config option: ${JSON.stringify(
configOption
)}`
);
} else {
newOptions[option] = value;
}
} else {
console.warn(
`None of the config values was selected. Values were: ${JSON.stringify(
values
`None of the config values was selected. Config options was: ${JSON.stringify(
configOption
)}`
);
return undefined;
}
return selectedValue.value;
};
const options = configOptions
.map(({ option, values }) => [option, toValue(values)])
.filter(([, value]) => !!value)
}

// Collect all options from the FQBN. Call them existing.
// Collect all incoming options to decorate the FQBN with. Call them new.
// To keep the order, iterate through the existing ones and append to FQBN.
// If a new(er) value exists for the same option, use the new value.
// If there is a new value, "mark" it as visited by deleting it from new. Otherwise, use the existing value.
// Append all new ones to the FQBN.
const mergedOptions: Record<string, string> = {};
for (const existing of Object.entries(existingOptions)) {
const [option, value] = existing;
const newValue = newOptions[option];
if (newValue) {
mergedOptions[option] = newValue;
delete newOptions[option];
} else {
mergedOptions[option] = value;
}
}
Array.from(Object.entries(newOptions)).forEach(
([option, value]) => (mergedOptions[option] = value)
);

const configSuffix = Object.entries(mergedOptions)
.map(([option, value]) => `${option}=${value}`)
.join(',');

return `${fqbn}:${options}`;
return `${vendor}:${arch}:${id}${configSuffix ? `:${configSuffix}` : ''}`;
}

export class ConfigOptionError extends Error {
Expand Down
115 changes: 115 additions & 0 deletions arduino-ide-extension/src/test/common/boards-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { expect } from 'chai';
import {
AttachedBoardsChangeEvent,
BoardInfo,
ConfigOption,
getBoardInfo,
noNativeSerialPort,
nonSerialPort,
Expand Down Expand Up @@ -93,6 +94,120 @@ describe('boards-service', () => {
});
});

describe('ConfigOption#decorate', () => {
['invalid', 'a:b:c:foo=', 'a:b:c:foo=bar=baz', 'a:b:c:foo+bar'].map(
(fqbn) =>
it(`should error when the FQBN is invalid (FQBN: ${fqbn})`, () => {
expect(() => ConfigOption.decorate(fqbn, [])).to.throw(
/^Invalid FQBN:*/
);
})
);

it('should not append the trailing boards config part to FQBN if configs is empty', () => {
const fqbn = 'a:b:c';
const actual = ConfigOption.decorate(fqbn, []);
expect(actual).to.be.equal(fqbn);
});

it('should be noop when config options is empty', () => {
const fqbn = 'a:b:c:menu1=value1';
const actual = ConfigOption.decorate(fqbn, []);
expect(actual).to.be.equal(fqbn);
});

it('should not duplicate config options', () => {
const fqbn = 'a:b:c:menu1=value1';
const actual = ConfigOption.decorate(fqbn, [
{
label: 'Menu 1',
option: 'menu1',
values: [
{ label: 'Value 1', value: 'value1', selected: true },
{ label: 'Another Value', value: 'another1', selected: false },
],
},
]);
expect(actual).to.be.equal(fqbn);
});

it('should update config options', () => {
const fqbn = 'a:b:c:menu1=value1,menu2=value2';
const actual = ConfigOption.decorate(fqbn, [
{
label: 'Menu 1',
option: 'menu1',
values: [
{ label: 'Value 1', value: 'value1', selected: false },
{ label: 'Another Value', value: 'another1', selected: true },
],
},
]);
expect(actual).to.be.equal('a:b:c:menu1=another1,menu2=value2');
});

it('should favor first over rest when there are duplicate options (duplicate on FQBN)', () => {
const fqbn = 'a:b:c:menu1=value1,menu1=value2';
const actual = ConfigOption.decorate(fqbn, [
{
label: 'Menu 1',
option: 'menu1',
values: [
{ label: 'Value 1', value: 'value1', selected: false },
{ label: 'Another Value', value: 'another1', selected: true },
],
},
]);
expect(actual).to.be.equal('a:b:c:menu1=another1');
});

it('should favor first over rest when there are duplicate options (duplicate in config)', () => {
const fqbn = 'a:b:c';
const actual = ConfigOption.decorate(fqbn, [
{
label: 'Menu 1',
option: 'menu1',
values: [
{ label: 'Value 1', value: 'value1', selected: false },
{ label: 'Another Value', value: 'another1', selected: true },
],
},
{
label: 'Menu 1',
option: 'menu1',
values: [
{ label: 'Value 1', value: 'value1', selected: true },
{ label: 'Another Value', value: 'another1', selected: false },
],
},
]);
expect(actual).to.be.equal('a:b:c:menu1=another1');
});

it('should not change the existing config order', () => {
const fqbn = 'a:b:c:menu1=value1';
const actual = ConfigOption.decorate(fqbn, [
{
label: 'Menu 0',
option: 'menu0',
values: [
{ label: 'Value 0', value: 'value0', selected: true },
{ label: 'Another Value', value: 'another0', selected: false },
],
},
{
label: 'Menu 1',
option: 'menu1',
values: [
{ label: 'Value 1', value: 'value1', selected: false },
{ label: 'Another Value', value: 'another1', selected: true },
],
},
]);
expect(actual).to.be.equal('a:b:c:menu1=another1,menu0=value0');
});
});

describe('getBoardInfo', () => {
const vid = '0x0';
const pid = '0x1';
Expand Down
Loading