Skip to content

fix: enforce valid sketch folder name on Save as #1821

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

Closed
wants to merge 1 commit into from
Closed
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
@@ -344,6 +344,7 @@ import { DebugViewModel } from '@theia/debug/lib/browser/view/debug-view-model';
import { DebugSessionWidget } from '@theia/debug/lib/browser/view/debug-session-widget';
import { DebugConfigurationWidget } from '@theia/debug/lib/browser/view/debug-configuration-widget';
import { ConfigServiceClient } from './config/config-service-client';
import { ValidateSketch } from './contributions/validate-sketch';

export default new ContainerModule((bind, unbind, isBound, rebind) => {
// Commands and toolbar items
@@ -729,6 +730,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => {
Contribution.configure(bind, UpdateIndexes);
Contribution.configure(bind, InterfaceScale);
Contribution.configure(bind, NewCloudSketch);
Contribution.configure(bind, ValidateSketch);

bindContributionProvider(bind, StartupTaskProvider);
bind(StartupTaskProvider).toService(BoardsServiceProvider); // to reuse the boards config in another window
Original file line number Diff line number Diff line change
@@ -30,7 +30,13 @@ import { CloudSketchbookTreeWidget } from '../widgets/cloud-sketchbook/cloud-ske
import { SketchbookCommands } from '../widgets/sketchbook/sketchbook-commands';
import { SketchbookWidget } from '../widgets/sketchbook/sketchbook-widget';
import { SketchbookWidgetContribution } from '../widgets/sketchbook/sketchbook-widget-contribution';
import { Command, CommandRegistry, Contribution, URI } from './contribution';
import {
Command,
CommandRegistry,
Contribution,
Sketch,
URI,
} from './contribution';

@injectable()
export class NewCloudSketch extends Contribution {
@@ -234,14 +240,7 @@ export class NewCloudSketch extends Contribution {
input
);
}
// This is how https://create.arduino.cc/editor/ works when renaming a sketch.
if (/^[0-9a-zA-Z_]{1,36}$/.test(input)) {
return '';
}
return nls.localize(
'arduino/newCloudSketch/invalidSketchName',
'The name must consist of basic letters, numbers, or underscores. The maximum length is 36 characters.'
);
return Sketch.validateCloudSketchFolderName(input) ?? '';
},
},
this.labelProvider,
77 changes: 64 additions & 13 deletions arduino-ide-extension/src/browser/contributions/save-as-sketch.ts
Original file line number Diff line number Diff line change
@@ -3,6 +3,7 @@ import * as remote from '@theia/core/electron-shared/@electron/remote';
import * as dateFormat from 'dateformat';
import { ArduinoMenus } from '../menu/arduino-menus';
import {
Sketch,
SketchContribution,
URI,
Command,
@@ -90,20 +91,9 @@ export class SaveAsSketch extends SketchContribution {
: sketch.name
);
const defaultPath = await this.fileService.fsPath(defaultUri);
const { filePath, canceled } = await remote.dialog.showSaveDialog(
remote.getCurrentWindow(),
{
title: nls.localize(
'arduino/sketch/saveFolderAs',
'Save sketch folder as...'
),
defaultPath,
}
const destinationUri = await this.promptSketchFolderDestination(
defaultPath
);
if (!filePath || canceled) {
return false;
}
const destinationUri = await this.fileSystemExt.getUri(filePath);
if (!destinationUri) {
return false;
}
@@ -133,6 +123,67 @@ export class SaveAsSketch extends SketchContribution {
return !!workspaceUri;
}

/**
* Prompts for the new sketch folder name until a valid one is give,
* then resolves with the destination sketch folder URI string,
* or `undefined` if the operation was canceled.
*/
private async promptSketchFolderDestination(
defaultPath: string
): Promise<string | undefined> {
let sketchFolderDestinationUri: string | undefined;
while (!sketchFolderDestinationUri) {
const { filePath } = await remote.dialog.showSaveDialog(
remote.getCurrentWindow(),
{
title: nls.localize(
'arduino/sketch/saveFolderAs',
'Save sketch folder as...'
),
defaultPath,
}
);
if (!filePath) {
return undefined;
}
const destinationUri = await this.fileSystemExt.getUri(filePath);
const sketchFolderName = new URI(destinationUri).path.base;
const errorMessage = Sketch.validateSketchFolderName(sketchFolderName);
if (errorMessage) {
const message = `
${nls.localize(
'arduino/sketch/invalidSketchFolderNameTitle',
"Invalid sketch folder name: '{0}'",
sketchFolderName
)}
${errorMessage}
${nls.localize(
'arduino/sketch/editInvalidSketchFolderName',
'Do you want to try to save the sketch folder with a different name?'
)}`.trim();
defaultPath = filePath;
const { response } = await remote.dialog.showMessageBox(
remote.getCurrentWindow(),
{
message,
buttons: [
nls.localize('vscode/issueMainService/cancel', 'Cancel'),
nls.localize('vscode/extensionsUtils/yes', 'Yes'),
],
});
// cancel
if (response === 0) {
return undefined;
}
} else {
sketchFolderDestinationUri = destinationUri;
}
}
return sketchFolderDestinationUri;
}

private async saveOntoCopiedSketch(mainFileUri: string, sketchUri: string, newSketchUri: string): Promise<void> {
const widgets = this.applicationShell.widgets;
const snapshots = new Map<string, object>();
171 changes: 171 additions & 0 deletions arduino-ide-extension/src/browser/contributions/validate-sketch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import * as remote from '@theia/core/electron-shared/@electron/remote';
import { Dialog } from '@theia/core/lib/browser/dialogs';
import { nls } from '@theia/core/lib/common/nls';
import { Deferred } from '@theia/core/lib/common/promise-util';
import { injectable } from '@theia/core/shared/inversify';
import { WorkspaceCommands } from '@theia/workspace/lib/browser/workspace-commands';
import { CurrentSketch } from '../../common/protocol/sketches-service-client-impl';
import { Sketch, SketchContribution, URI } from './contribution';
import { SaveAsSketch } from './save-as-sketch';

@injectable()
export class ValidateSketch extends SketchContribution {
override onReady(): void {
this.validate();
}

private async validate(): Promise<void> {
const result = await this.promptFixActions();
if (!result) {
const yes = await this.prompt(
nls.localize('arduino/validateSketch/abortFixTitle', 'Invalid sketch'),
nls.localize(
'arduino/validateSketch/abortFixMessage',
"The sketch is still invalid. Do you want to fix the remaining problems? By clicking '{0}', a new sketch will open.",
Dialog.NO
),
[Dialog.NO, Dialog.YES]
);
if (yes) {
return this.validate();
}
const sketch = await this.sketchService.createNewSketch();
this.workspaceService.open(new URI(sketch.uri), {
preserveWindow: true,
});
}
}

/**
* Returns with an array of actions the user has to perform to fix the invalid sketch.
*/
private validateSketch(sketch: Sketch): FixAction[] {
// sketch folder + main sketch file (requires `Save as...` and window reload)
const sketchFolderName = new URI(sketch.uri).path.base;
const sketchFolderNameError =
Sketch.validateSketchFolderName(sketchFolderName);
if (sketchFolderNameError) {
return [
{
execute: async () => {
const unknown =
(await this.promptRenameSketch(sketch)) &&
(await this.commandService.executeCommand(
SaveAsSketch.Commands.SAVE_AS_SKETCH.id,
<SaveAsSketch.Options>{
markAsRecentlyOpened: true,
openAfterMove: true,
wipeOriginal: true,
}
));
return !!unknown;
},
},
];
}

// sketch code files (does not require window reload)
return Sketch.uris(sketch)
.filter((uri) => uri !== sketch.mainFileUri)
.map((uri) => new URI(uri))
.filter((uri) => Sketch.Extensions.CODE_FILES.includes(uri.path.ext))
.map((uri) => ({
uri,
error: Sketch.validateSketchFolderName(uri.path.name),
}))
.filter(({ error }) => Boolean(error))
.map(({ uri }) => ({
execute: async () => {
const unknown =
(await this.promptRenameSketchFile(uri)) &&
(await this.commandService.executeCommand(
WorkspaceCommands.FILE_RENAME.id,
uri
));
return !!unknown;
},
}));
}

private async currentSketch(): Promise<Sketch> {
const sketch = this.sketchServiceClient.tryGetCurrentSketch();
if (CurrentSketch.isValid(sketch)) {
return sketch;
}
const deferred = new Deferred<Sketch>();
const disposable = this.sketchServiceClient.onCurrentSketchDidChange(
(sketch) => {
if (CurrentSketch.isValid(sketch)) {
disposable.dispose();
deferred.resolve(sketch);
}
}
);
return deferred.promise;
}

private async promptFixActions(): Promise<boolean> {
const sketch = await this.currentSketch();
const fixActions = this.validateSketch(sketch);
for (const fixAction of fixActions) {
const result = await fixAction.execute();
if (!result) {
return false;
}
}
return true;
}

private async promptRenameSketch(sketch: Sketch): Promise<boolean> {
return this.prompt(
nls.localize(
'arduino/validateSketch/renameSketchFolderTitle',
'Invalid sketch name'
),
nls.localize(
'arduino/validateSketch/renameSketchFolderMessage',
"The sketch '{0}' cannot be used. Sketch names must start with a letter or number, followed by letters, numbers, dashes, dots and underscores. Maximum length is 63 characters. To get rid of this message, rename the sketch. Do you want to rename the sketch now?",
sketch.name
)
);
}

private async promptRenameSketchFile(uri: URI): Promise<boolean> {
return this.prompt(
nls.localize(
'arduino/validateSketch/renameSketchFileTitle',
'Invalid sketch filename'
),
nls.localize(
'arduino/validateSketch/renameSketchFileMessage',
"The sketch file '{0}' cannot be used. Sketch filenames must start with a letter or number, followed by letters, numbers, dashes, dots and underscores. Maximum length is 63 characters without the file extension. To get rid of this message, rename the sketch file. Do you want to rename the sketch file now?",
uri.path.base
)
);
}

private async prompt(
title: string,
message: string,
buttons: string[] = [Dialog.CANCEL, Dialog.OK]
): Promise<boolean> {
const { response } = await remote.dialog.showMessageBox(
remote.getCurrentWindow(),
{
title,
message,
type: 'warning',
buttons,
}
);
// cancel
if (response === 0) {
return false;
}
return true;
}
}

interface FixAction {
execute(): Promise<boolean>;
}
Original file line number Diff line number Diff line change
@@ -27,7 +27,7 @@ export interface VerifySketchParams {
}

/**
* - `"idle"` when neither verify, not upload is running,
* - `"idle"` when neither verify, nor upload is running,
* - `"explicit-verify"` when only verify is running triggered by the user, and
* - `"automatic-verify"` is when the automatic verify phase is running as part of an upload triggered by the user.
*/
Original file line number Diff line number Diff line change
@@ -1,34 +1,32 @@
import { inject, injectable } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { open } from '@theia/core/lib/browser/opener-service';
import { FileStat } from '@theia/filesystem/lib/common/files';
import { nls } from '@theia/core/lib/common';
import {
CommandRegistry,
CommandService,
} from '@theia/core/lib/common/command';
import { Path } from '@theia/core/lib/common/path';
import URI from '@theia/core/lib/common/uri';
import { inject, injectable } from '@theia/core/shared/inversify';
import { FileStat } from '@theia/filesystem/lib/common/files';
import {
WorkspaceCommandContribution as TheiaWorkspaceCommandContribution,
WorkspaceCommands,
} from '@theia/workspace/lib/browser/workspace-commands';
import { Sketch, SketchesService } from '../../../common/protocol';
import { WorkspaceInputDialog } from './workspace-input-dialog';
import {
CurrentSketch,
SketchesServiceClientImpl,
} from '../../../common/protocol/sketches-service-client-impl';
import { SaveAsSketch } from '../../contributions/save-as-sketch';
import { nls } from '@theia/core/lib/common';
import { WorkspaceInputDialog } from './workspace-input-dialog';

@injectable()
export class WorkspaceCommandContribution extends TheiaWorkspaceCommandContribution {
@inject(SketchesServiceClientImpl)
protected readonly sketchesServiceClient: SketchesServiceClientImpl;

@inject(CommandService)
protected readonly commandService: CommandService;

private readonly commandService: CommandService;
@inject(SketchesService)
protected readonly sketchService: SketchesService;
private readonly sketchService: SketchesService;
@inject(SketchesServiceClientImpl)
private readonly sketchesServiceClient: SketchesServiceClientImpl;

override registerCommands(registry: CommandRegistry): void {
super.registerCommands(registry);
@@ -48,7 +46,7 @@ export class WorkspaceCommandContribution extends TheiaWorkspaceCommandContribut
);
}

protected async newFile(uri: URI | undefined): Promise<void> {
private async newFile(uri: URI | undefined): Promise<void> {
if (!uri) {
return;
}
@@ -68,50 +66,39 @@ export class WorkspaceCommandContribution extends TheiaWorkspaceCommandContribut
);

const name = await dialog.open();
const nameWithExt = this.maybeAppendInoExt(name);
if (nameWithExt) {
const fileUri = parentUri.resolve(nameWithExt);
await this.fileService.createFile(fileUri);
this.fireCreateNewFile({ parent: parentUri, uri: fileUri });
open(this.openerService, fileUri);
if (!name) {
return;
}
const nameWithExt = this.maybeAppendInoExt(name);
const fileUri = parentUri.resolve(nameWithExt);
await this.fileService.createFile(fileUri);
this.fireCreateNewFile({ parent: parentUri, uri: fileUri });
open(this.openerService, fileUri);
}

protected override async validateFileName(
name: string,
userInput: string,
parent: FileStat,
recursive = false
): Promise<string> {
// In the Java IDE the followings are the rules:
// - `name` without an extension should default to `name.ino`.
// - `name` with a single trailing `.` also defaults to `name.ino`.
const nameWithExt = this.maybeAppendInoExt(name);
const errorMessage = await super.validateFileName(
nameWithExt,
parent,
recursive
);
if (errorMessage) {
return errorMessage;
// If name does not have extension or ends with trailing dot (from IDE 1.x), treat it as an .ino file.
// If has extension,
// - if unsupported extension -> error
// - if has a code file extension -> apply folder name validation without the extension and use the Theia-based validation
// - if has any additional file extension -> use the default Theia-based validation
const fileInput = parseFileInput(userInput);
const { name, extension } = fileInput;
if (!Sketch.Extensions.ALL.includes(extension)) {
return invalidExtension(extension);
}
const extension = nameWithExt.split('.').pop();
if (!extension) {
return nls.localize(
'theia/workspace/invalidFilename',
'Invalid filename.'
); // XXX: this should not happen as we forcefully append `.ino` if it's not there.
}
if (Sketch.Extensions.ALL.indexOf(`.${extension}`) === -1) {
return nls.localize(
'theia/workspace/invalidExtension',
'.{0} is not a valid extension',
extension
);
let errorMessage: string | undefined = undefined;
if (Sketch.Extensions.CODE_FILES.includes(extension)) {
errorMessage = Sketch.validateSketchFolderName(name);
}
return '';
return errorMessage ?? super.validateFileName(userInput, parent, recursive);
}

protected maybeAppendInoExt(name: string | undefined): string {
private maybeAppendInoExt(name: string): string {
if (!name) {
return '';
}
@@ -126,7 +113,7 @@ export class WorkspaceCommandContribution extends TheiaWorkspaceCommandContribut
return name;
}

protected async renameFile(uri: URI | undefined): Promise<void> {
protected async renameFile(uri: URI | undefined): Promise<unknown> {
if (!uri) {
return;
}
@@ -149,11 +136,10 @@ export class WorkspaceCommandContribution extends TheiaWorkspaceCommandContribut
openAfterMove: true,
wipeOriginal: true,
};
await this.commandService.executeCommand(
SaveAsSketch.Commands.SAVE_AS_SKETCH.id,
return await this.commandService.executeCommand<string>(
'arduino-save-as-sketch',
options
);
return;
}
const parent = await this.getParent(uri);
if (!parent) {
@@ -180,12 +166,57 @@ export class WorkspaceCommandContribution extends TheiaWorkspaceCommandContribut
},
this.labelProvider
);
const newName = await dialog.open();
const newNameWithExt = this.maybeAppendInoExt(newName);
if (newNameWithExt) {
const oldUri = uri;
const newUri = uri.parent.resolve(newNameWithExt);
this.fileService.move(oldUri, newUri);
const name = await dialog.open();
if (!name) {
return;
}
const nameWithExt = this.maybeAppendInoExt(name);
const oldUri = uri;
const newUri = uri.parent.resolve(nameWithExt);
return this.fileService.move(oldUri, newUri);
}
}

export function invalidExtension(
extension: string
): string | PromiseLike<string> {
return nls.localize(
'theia/workspace/invalidExtension',
'.{0} is not a valid extension',
extension.charAt(0) === '.' ? extension.slice(1) : extension
);
}

interface FileInput {
/**
* The raw text the user enters in the `<input>`.
*/
readonly raw: string;
/**
* This is the name without the extension. If raw is `'lib.cpp'`, then `name` will be `'lib'`. If raw is `'foo'` or `'foo.'` this value is `'foo'`.
*/
readonly name: string;
/**
* With the leading dot. For example `'.ino'` or `'.cpp'`.
*/
readonly extension: string;
}
export function parseFileInput(userInput: string): FileInput {
if (!userInput) {
return {
raw: '',
name: '',
extension: Sketch.Extensions.DEFAULT,
};
}
const path = new Path(userInput);
let extension = path.ext;
if (extension.trim() === '' || extension.trim() === '.') {
extension = Sketch.Extensions.DEFAULT;
}
return {
raw: userInput,
name: path.name,
extension,
};
}
49 changes: 48 additions & 1 deletion arduino-ide-extension/src/common/protocol/sketches-service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ApplicationError } from '@theia/core/lib/common/application-error';
import { nls } from '@theia/core/lib/common/nls';
import URI from '@theia/core/lib/common/uri';

export namespace SketchesError {
@@ -151,6 +152,51 @@ export interface Sketch extends SketchRef {
readonly rootFolderFileUris: string[]; // `RootFolderFiles` (does not include the main sketch file)
}
export namespace Sketch {
// (non-API) exported for the tests
export const invalidSketchFolderNameMessage = nls.localize(
'arduino/sketch/invalidSketchName',
'Sketch names must start with a letter or number, followed by letters, numbers, dashes, dots and underscores. Maximum length is 63 characters.'
);
const invalidCloudSketchFolderNameMessage = nls.localize(
'arduino/sketch/invalidCloudSketchName',
'The name must consist of basic letters, numbers, or underscores. The maximum length is 36 characters.'
);
/**
* `undefined` if the candidate sketch folder name is valid. Otherwise, the validation error message.
* Based on the [specs](https://arduino.github.io/arduino-cli/latest/sketch-specification/#sketch-folders-and-files).
*/
export function validateSketchFolderName(
candidate: string
): string | undefined {
return /^[0-9a-zA-Z]{1}[0-9a-zA-Z_\.-]{0,62}$/.test(candidate)
? undefined
: invalidSketchFolderNameMessage;
}

/**
* `undefined` if the candidate cloud sketch folder name is valid. Otherwise, the validation error message.
* Based on how https://create.arduino.cc/editor/ works.
*/
export function validateCloudSketchFolderName(
candidate: string
): string | undefined {
return /^[0-9a-zA-Z_]{1,36}$/.test(candidate)
Copy link
Contributor

@per1234 per1234 Jan 25, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to get a confirmation that it really is necessary to disallow the - and . characters in Cloud sketches. That seems a completely unnecessary departure from the Arduino Sketch Specification to me.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I started an internal dialog.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return /^[0-9a-zA-Z_]{1,36}$/.test(candidate)
return /^[0-9a-zA-Z]{1}[0-9a-zA-Z_]{0,35}$/.test(candidate)

I'm ready to accept that there is some hypothetical reason why the maximum length must be more restrictive for Cloud sketch. At least in this case a valid cloud sketch name length is still in compliance with the specification, even though the converse is not true.

But it is unacceptable to allow Cloud sketch names that are in violation of the Arduino Sketch Specification.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But it is unacceptable to allow Cloud sketch names that are in violation of the Arduino Sketch Specification.

It was requested in one of the design files that IDE2 must rename sketches when cloning from and pushing to the cloud. We need to add tests to verify IDE2 can do the name transformation.

Users can still create and pull a remote sketch, so relaxing the regex here might not help. What do you think?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Users can still create and pull a remote sketch, so relaxing the regex here might not help. What do you think?

I don't think it is a problem because the new approach is to validate names when the sketch is opened. So even if an invalid sketch is pulled, it will still be at the time the user opens the pulled sketch.

I'm referring to this feature:

#1821 (comment)

  • When you open an invalid sketch, IDE2 will prompt for user actions to fix the issues.
    • First, IDE2 prompts for the sketch folder name fix as a Save as... operation. IDE2 prompt until the user specifies a valid folder name or aborts the operation.
    • If the sketch folder name has been corrected, IDE2 reloads and IDE2 validates the code files
    • If there are any invalid code files, the user has to fix them as a Rename operation.
    • When all problems have been fixed, IDE2 is ready for use. If no, IDE2 prompts users whether they want to interrupt the operation and navigate to a new temp sketch.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it is a problem because the new approach is to validate names when the sketch is opened. So even if an invalid sketch is pulled, it will still be at the time the user opens the pulled sketch.

You're correct, but it only covers some user cases. After IDE2 pulls the cloud sketch and fixes any possible sketch folder name and code file name errors, the user can create a file remotely and pull it again. It will be spec incompatible if the name of the code file starts with an underscore. Please correct me if I am overlooking something. Thanks!

IDE2 may run a validation after pulling from the remote.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the user can create a file remotely and pull it again. It will be spec incompatible if the name of the code file starts with an underscore. Please correct me if I am overlooking something.

You are right that this would provide a path to having a non-compliant sketch open in Arduino IDE. However, that will only be possible during that session. The next time the user tries to open the sketch, the added file will be validated.

So I wouldn't consider this scenario to completely invalidate the proposed approach of only doing the filename validation on an open operation.

Ideally validation would also be done when a file is added or renamed through an "external" process such as a pull. There is an equivalent scenario for local sketches in that the user may add or rename files of the open sketch externally. But I don't consider such validation to be absolutely essential. The most important reason for validating against the specification is to ensure shared sketches are compatible with any official Arduino development tool as well as 3rd party tools from conscientious developers. I think it would be rare that a developer would not end up later opening the type of sketch that is shared (as opposed to a quick throwaway test sketch we might write and use during a single IDE session and never look at again).

So if it is relatively easy to add a validation triggered by external addition or name change of sketch file, then great. But if this is something of significant difficulty to implement then I think the "on open" approach is sufficient.

? undefined
: invalidCloudSketchFolderNameMessage;
}

/**
* Transforms the valid local sketch name into a valid cloud sketch name by replacing dots and dashes with underscore and trimming the length after 36 characters.
* Throws an error if `candidate` is not valid.
*/
export function toValidCloudSketchFolderName(candidate: string): string {
const errorMessage = validateSketchFolderName(candidate);
if (errorMessage) {
throw new Error(errorMessage);
}
return candidate.replace(/\./g, '_').replace(/-/g, '_').slice(0, 36);
}

export function is(arg: unknown): arg is Sketch {
if (!SketchRef.is(arg)) {
return false;
@@ -172,7 +218,8 @@ export namespace Sketch {
return false;
}
export namespace Extensions {
export const MAIN = ['.ino', '.pde'];
export const DEFAULT = '.ino';
export const MAIN = [DEFAULT, '.pde'];
export const SOURCE = ['.c', '.cpp', '.S'];
export const CODE_FILES = [...MAIN, ...SOURCE, '.h', '.hh', '.hpp'];
export const ADDITIONAL = [...CODE_FILES, '.json', '.md', '.adoc'];
215 changes: 215 additions & 0 deletions arduino-ide-extension/src/test/browser/workspace-commands.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
const disableJSDOM = enableJSDOM();

import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
FrontendApplicationConfigProvider.set({});

import {
FrontendApplication,
LabelProvider,
OpenerService,
} from '@theia/core/lib/browser';
import { ClipboardService } from '@theia/core/lib/browser/clipboard-service';
import { ApplicationServer } from '@theia/core/lib/common/application-protocol';
import { CommandService } from '@theia/core/lib/common/command';
import { MessageService } from '@theia/core/lib/common/message-service';
import { nls } from '@theia/core/lib/common/nls';
import { OS } from '@theia/core/lib/common/os';
import { SelectionService } from '@theia/core/lib/common/selection-service';
import URI from '@theia/core/lib/common/uri';
import { Container } from '@theia/core/shared/inversify';
import { FileDialogService } from '@theia/filesystem/lib/browser';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { FileStat } from '@theia/filesystem/lib/common/files';
import { WorkspaceCompareHandler } from '@theia/workspace/lib/browser/workspace-compare-handler';
import { WorkspaceDeleteHandler } from '@theia/workspace/lib/browser/workspace-delete-handler';
import { WorkspaceDuplicateHandler } from '@theia/workspace/lib/browser/workspace-duplicate-handler';
import { WorkspacePreferences } from '@theia/workspace/lib/browser/workspace-preferences';
import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
import { expect } from 'chai';
import {
invalidExtension as invalidExtensionMessage,
parseFileInput,
WorkspaceCommandContribution,
} from '../../browser/theia/workspace/workspace-commands';
import { Sketch, SketchesService } from '../../common/protocol';
import { SketchesServiceClientImpl } from '../../common/protocol/sketches-service-client-impl';

disableJSDOM();

describe('workspace-commands', () => {
describe('parseFileInput', () => {
it("should parse input without extension as '.ino'", () => {
const actual = parseFileInput('foo');
expect(actual).to.be.deep.equal({
raw: 'foo',
name: 'foo',
extension: '.ino',
});
});
it("should parse input with a trailing dot as '.ino'", () => {
const actual = parseFileInput('foo.');
expect(actual).to.be.deep.equal({
raw: 'foo.',
name: 'foo',
extension: '.ino',
});
});
it('should parse input with a valid extension', () => {
const actual = parseFileInput('lib.cpp');
expect(actual).to.be.deep.equal({
raw: 'lib.cpp',
name: 'lib',
extension: '.cpp',
});
});
it('should calculate the file extension based on the last dot index', () => {
const actual = parseFileInput('lib.ino.x');
expect(actual).to.be.deep.equal({
raw: 'lib.ino.x',
name: 'lib.ino',
extension: '.x',
});
});
it('should ignore trailing spaces after the last dot', () => {
const actual = parseFileInput(' foo. ');
expect(actual).to.be.deep.equal({
raw: ' foo. ',
name: ' foo',
extension: '.ino',
});
});
});

describe('validateFileName', () => {
const child: FileStat = {
isFile: true,
isDirectory: false,
isSymbolicLink: false,
resource: new URI('sketch/sketch.ino'),
name: 'sketch.ino',
};
const parent: FileStat = {
isFile: false,
isDirectory: true,
isSymbolicLink: false,
resource: new URI('sketch'),
name: 'sketch',
children: [child],
};

let workspaceCommands: WorkspaceCommandContribution;
const trimmedName = (name: string) =>
workspaceCommands['trimFileName'](name);

async function testMe(userInput: string): Promise<string> {
return workspaceCommands['validateFileName'](userInput, parent);
}

function createContainer(): Container {
const container = new Container();
container.bind(FileDialogService).toConstantValue(<FileDialogService>{});
container.bind(FileService).toConstantValue(<FileService>{
async exists(resource: URI): Promise<boolean> {
return (
resource.path.base.includes('_sketch') ||
resource.path.base.includes('sketch')
);
},
});
container
.bind(FrontendApplication)
.toConstantValue(<FrontendApplication>{});
container.bind(LabelProvider).toConstantValue(<LabelProvider>{});
container.bind(MessageService).toConstantValue(<MessageService>{});
container.bind(OpenerService).toConstantValue(<OpenerService>{});
container.bind(SelectionService).toConstantValue(<SelectionService>{});
container.bind(WorkspaceCommandContribution).toSelf().inSingletonScope();
container
.bind(WorkspaceCompareHandler)
.toConstantValue(<WorkspaceCompareHandler>{});
container
.bind(WorkspaceDeleteHandler)
.toConstantValue(<WorkspaceDeleteHandler>{});
container
.bind(WorkspaceDuplicateHandler)
.toConstantValue(<WorkspaceDuplicateHandler>{});
container
.bind(WorkspacePreferences)
.toConstantValue(<WorkspacePreferences>{});
container.bind(WorkspaceService).toConstantValue(<WorkspaceService>{});
container.bind(ClipboardService).toConstantValue(<ClipboardService>{});
container.bind(ApplicationServer).toConstantValue(<ApplicationServer>{
async getBackendOS(): Promise<OS.Type> {
return OS.type();
},
});
container.bind(CommandService).toConstantValue(<CommandService>{});
container.bind(SketchesService).toConstantValue(<SketchesService>{});
container
.bind(SketchesServiceClientImpl)
.toConstantValue(<SketchesServiceClientImpl>{});
return container;
}

beforeEach(() => {
workspaceCommands = createContainer().get<WorkspaceCommandContribution>(
WorkspaceCommandContribution
);
});

it("should validate input string without an extension as an '.ino' file", async () => {
const actual = await testMe('valid');
expect(actual).to.be.empty;
});

it('code files cannot start with number (no extension)', async () => {
const actual = await testMe('_invalid');
expect(actual).to.be.equal(Sketch.invalidSketchFolderNameMessage);
});

it('code files cannot start with number (trailing dot)', async () => {
const actual = await testMe('_invalid.');
expect(actual).to.be.equal(Sketch.invalidSketchFolderNameMessage);
});

it('code files cannot start with number (trailing dot)', async () => {
const actual = await testMe('_invalid.cpp');
expect(actual).to.be.equal(Sketch.invalidSketchFolderNameMessage);
});

it('should warn about invalid extension first', async () => {
const actual = await testMe('_invalid.xxx');
expect(actual).to.be.equal(invalidExtensionMessage('.xxx'));
});

it('should not warn about invalid file extension for empty input', async () => {
const actual = await testMe('');
expect(actual).to.be.equal(Sketch.invalidSketchFolderNameMessage);
});

it('should ignore non-code filename validation from the spec', async () => {
const actual = await testMe('_invalid.json');
expect(actual).to.be.empty;
});

it('non-code files should be validated against default new file validation rules', async () => {
const name = ' invalid.json';
const actual = await testMe(name);
const expected = nls.localizeByDefault(
'Leading or trailing whitespace detected in file or folder name.'
);
expect(actual).to.be.equal(expected);
});

it('should warn about existing resource', async () => {
const name = 'sketch.ino';
const actual = await testMe(name);
const expected = nls.localizeByDefault(
'A file or folder **{0}** already exists at this location. Please choose a different name.',
trimmedName(name)
);
expect(actual).to.be.equal(expected);
});
});
});
119 changes: 119 additions & 0 deletions arduino-ide-extension/src/test/common/sketches-service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { expect } from 'chai';
import { Sketch } from '../../common/protocol';

describe('sketch', () => {
describe('validateSketchFolderName', () => {
(
[
['sketch', true],
['can-contain-slash-and-dot.ino', true],
['regex++', false],
['dots...', true],
['No Spaces', false],
['_invalidToStartWithUnderscore', false],
['Invalid+Char.ino', false],
['', false],
['/', false],
['//trash/', false],
[
'63Length_012345678901234567890123456789012345678901234567890123',
true,
],
[
'TooLong__0123456789012345678901234567890123456789012345678901234',
false,
],
] as [string, boolean][]
).map(([input, expected]) => {
it(`'${input}' should ${
!expected ? 'not ' : ''
}be a valid sketch folder name`, () => {
const actual = Sketch.validateSketchFolderName(input);
if (expected) {
expect(actual).to.be.undefined;
} else {
expect(actual).to.be.not.undefined;
expect(actual?.length).to.be.greaterThan(0);
}
});
});
});

describe('validateCloudSketchFolderName', () => {
(
[
['sketch', true],
['no-dashes', false],
['no-dots', false],
['No Spaces', false],
['_canStartWithUnderscore', true],
['Invalid+Char.ino', false],
['', false],
['/', false],
['//trash/', false],
['36Length_012345678901234567890123456', true],
['TooLong__0123456789012345678901234567', false],
] as [string, boolean][]
).map(([input, expected]) => {
it(`'${input}' should ${
!expected ? 'not ' : ''
}be a valid cloud sketch folder name`, () => {
const actual = Sketch.validateCloudSketchFolderName(input);
if (expected) {
expect(actual).to.be.undefined;
} else {
expect(actual).to.be.not.undefined;
expect(actual?.length).to.be.greaterThan(0);
}
});
});
});

describe('toValidCloudSketchFolderName', () => {
(
[
['sketch', 'sketch'],
['can-contain-slash-and-dot.ino', 'can_contain_slash_and_dot_ino'],
['regex++'],
['dots...', 'dots___'],
['No Spaces'],
['_invalidToStartWithUnderscore'],
['Invalid+Char.ino'],
[''],
['/'],
['//trash/'],
[
'63Length_012345678901234567890123456789012345678901234567890123',
'63Length_012345678901234567890123456',
],
['TooLong__0123456789012345678901234567890123456789012345678901234'],
] as [string, string?][]
).map(([input, expected]) => {
it(`'${input}' should ${expected ? '' : 'not '}map the ${
!expected ? 'invalid ' : ''
}sketch folder name to a valid cloud sketch folder name${
expected ? `: '${expected}'` : ''
}`, () => {
if (!expected) {
try {
Sketch.toValidCloudSketchFolderName(input);
throw new Error(
`Expected an error when mapping ${input} to a valid sketch folder name.`
);
} catch (err) {
if (err instanceof Error) {
expect(err.message).to.be.equal(
Sketch.invalidSketchFolderNameMessage
);
} else {
throw err;
}
}
} else {
const actual = Sketch.toValidCloudSketchFolderName(input);
expect(actual).to.be.equal(expected);
}
});
});
});
});
Empty file.
Empty file.
Original file line number Diff line number Diff line change
@@ -162,6 +162,10 @@ const testSketchbookContainerTemplate: SketchContainer = {
name: 'bar++',
uri: 'template://bar%2B%2B',
},
{
name: 'bar++ 2',
uri: 'template://bar%2B%2B%202',
},
{
name: 'a_sketch',
uri: 'template://a_sketch',
14 changes: 12 additions & 2 deletions i18n/en.json
Original file line number Diff line number Diff line change
@@ -311,7 +311,6 @@
"unableToConnectToWebSocket": "Unable to connect to websocket"
},
"newCloudSketch": {
"invalidSketchName": "The name must consist of basic letters, numbers, or underscores. The maximum length is 36 characters.",
"newSketchTitle": "Name of a new Remote Sketch",
"notFound": "Could not pull the remote sketch '{0}'. It does not exist.",
"sketchAlreadyExists": "Remote sketch '{0}' already exists."
@@ -405,7 +404,11 @@
"createdArchive": "Created archive '{0}'.",
"doneCompiling": "Done compiling.",
"doneUploading": "Done uploading.",
"editInvalidSketchFolderName": "Do you want to try to save the sketch folder with a different name?",
"exportBinary": "Export Compiled Binary",
"invalidCloudSketchName": "The name must consist of basic letters, numbers, or underscores. The maximum length is 36 characters.",
"invalidSketchFolderNameTitle": "Invalid sketch folder name: '{0}'",
"invalidSketchName": "Sketch names must start with a letter or number, followed by letters, numbers, dashes, dots and underscores. Maximum length is 63 characters.",
"moving": "Moving",
"movingMsg": "The file \"{0}\" needs to be inside a sketch folder named \"{1}\".\nCreate this folder, move the file, and continue?",
"new": "New Sketch",
@@ -448,6 +451,14 @@
"cancel": "Cancel",
"enterField": "Enter {0}",
"upload": "Upload"
},
"validateSketch": {
"abortFixMessage": "The sketch is still invalid. Do you want to fix the remaining problems? By clicking '{0}', a new sketch will open.",
"abortFixTitle": "Invalid sketch",
"renameSketchFileMessage": "The sketch file '{0}' cannot be used. Sketch filenames must start with a letter or number, followed by letters, numbers, dashes, dots and underscores. Maximum length is 63 characters without the file extension. To get rid of this message, rename the sketch file. Do you want to rename the sketch file now?",
"renameSketchFileTitle": "Invalid sketch filename",
"renameSketchFolderMessage": "The sketch '{0}' cannot be used. Sketch names must start with a letter or number, followed by letters, numbers, dashes, dots and underscores. Maximum length is 63 characters. To get rid of this message, rename the sketch. Do you want to rename the sketch now?",
"renameSketchFolderTitle": "Invalid sketch name"
}
},
"theia": {
@@ -470,7 +481,6 @@
"deleteCurrentSketch": "Do you want to delete the current sketch?",
"fileNewName": "Name for new file",
"invalidExtension": ".{0} is not a valid extension",
"invalidFilename": "Invalid filename.",
"newFileName": "New name for file"
}
}