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 { export const Codes = { NotFound: 5001, InvalidName: 5002, }; export const NotFound = ApplicationError.declare( Codes.NotFound, (message: string, uri: string) => { return { message, data: { uri }, }; } ); export const InvalidName = ApplicationError.declare( Codes.InvalidName, (message: string, invalidMainSketchUri: string) => { return { message, data: { invalidMainSketchUri }, }; } ); } export const SketchesServicePath = '/services/sketches-service'; export const SketchesService = Symbol('SketchesService'); export interface SketchesService { /** * Resolves to a sketch container representing the hierarchical structure of the sketches. * If `uri` is not given, `directories.user` will be user instead. The `sketchesInInvalidFolder` * array might contain sketches that were discovered, but due to their invalid name they were removed * from the `container`. */ getSketches({ uri }: { uri?: string }): Promise<{ container: SketchContainer; sketchesInInvalidFolder: SketchRef[]; }>; /** * This is the TS implementation of `SketchLoad` from the CLI and should be replaced with a gRPC call eventually. * See: https://github.com/arduino/arduino-cli/issues/837 * Based on: https://github.com/arduino/arduino-cli/blob/eef3705c4afcba4317ec38b803d9ffce5dd59a28/arduino/builder/sketch.go#L100-L215 */ loadSketch(uri: string): Promise<Sketch>; /** * Unlike `loadSketch`, this method gracefully resolves to `undefined` instead or rejecting if the `uri` is not a sketch folder. */ maybeLoadSketch(uri: string): Promise<Sketch | undefined>; /** * Creates a new sketch folder in the temp location. */ createNewSketch(): Promise<Sketch>; /** * Creates a new sketch with existing content. Rejects if `uri` is not pointing to a valid sketch folder. */ cloneExample(uri: string): Promise<Sketch>; isSketchFolder(uri: string): Promise<boolean>; /** * Sketches are created to the temp location by default and will be moved under `directories.user` on save. * This method resolves to `true` if the `sketch` is still in the temp location. Otherwise, `false`. */ isTemp(sketch: SketchRef): Promise<boolean>; /** * If `isTemp` is `true` for the `sketch`, you can call this method to move the sketch from the temp * location to `directories.user`. Resolves with the URI of the sketch after the move. Rejects, when the sketch * was not in the temp folder. This method always overrides. It's the callers responsibility to ask the user whether * the files at the destination can be overwritten or not. */ copy(sketch: Sketch, options: { destinationUri: string }): Promise<string>; /** * Returns with the container sketch for the input `uri`. If the `uri` is not in a sketch folder, the promise resolves to `undefined`. */ getSketchFolder(uri: string): Promise<Sketch | undefined>; /** * Marks the sketch with the given URI as recently opened. It does nothing if the sketch is temp or not valid. */ markAsRecentlyOpened(uri: string): Promise<void>; /** * Resolves to an array of sketches in inverse chronological order. The newest is the first. * If `forceUpdate` is `true`, the array of recently opened sketches will be recalculated. * Invalid and missing sketches will be removed from the list. It's `false` by default. */ recentlyOpenedSketches(forceUpdate?: boolean): Promise<Sketch[]>; /** * Archives the sketch, resolves to the archive URI. */ archive(sketch: Sketch, destinationUri: string): Promise<string>; /** * Counterpart of the CLI's `genBuildPath` functionality. * Based on https://github.com/arduino/arduino-cli/blob/550179eefd2d2bca299d50a4af9e9bfcfebec649/arduino/builder/builder.go#L30-L38 */ getIdeTempFolderUri(sketch: Sketch): Promise<string>; /** * Recursively deletes the sketch folder with all its content. */ deleteSketch(sketch: Sketch): Promise<void>; } export interface SketchRef { readonly name: string; readonly uri: string; // `LocationPath` } export namespace SketchRef { export function fromUri(uriLike: string | URI): SketchRef { const uri = typeof uriLike === 'string' ? new URI(uriLike) : uriLike; return { name: uri.path.base, uri: typeof uriLike === 'string' ? uriLike : uriLike.toString(), }; } export function is(arg: unknown): arg is SketchRef { if (typeof arg === 'object') { // eslint-disable-next-line @typescript-eslint/no-explicit-any const object = arg as any; return ( 'name' in object && typeof object['name'] === 'string' && 'uri' in object && typeof object['name'] === 'string' ); } return false; } } export interface Sketch extends SketchRef { readonly mainFileUri: string; // `MainFile` readonly otherSketchFileUris: string[]; // `OtherSketchFiles` readonly additionalFileUris: string[]; // `AdditionalFiles` 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) ? 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; } if (typeof arg === 'object') { // eslint-disable-next-line @typescript-eslint/no-explicit-any const object = arg as any; return ( 'mainFileUri' in object && typeof object['mainFileUri'] === 'string' && 'otherSketchFileUris' in object && Array.isArray(object['otherSketchFileUris']) && 'additionalFileUris' in object && Array.isArray(object['additionalFileUris']) && 'rootFolderFileUris' in object && Array.isArray(object['rootFolderFileUris']) ); } return false; } export namespace Extensions { export const MAIN = ['.ino', '.pde']; export const SOURCE = ['.c', '.cpp', '.S']; export const CODE_FILES = [...MAIN, ...SOURCE, '.h', '.hh', '.hpp']; export const ADDITIONAL = [...CODE_FILES, '.json', '.md', '.adoc']; export const ALL = Array.from(new Set([...MAIN, ...SOURCE, ...ADDITIONAL])); } export function isInSketch(uri: string | URI, sketch: Sketch): boolean { return uris(sketch).includes( typeof uri === 'string' ? uri : uri.toString() ); } export function isSketchFile(arg: string | URI): boolean { if (arg instanceof URI) { return isSketchFile(arg.toString()); } return Extensions.MAIN.some((ext) => arg.endsWith(ext)); } export function uris(sketch: Sketch): string[] { const { mainFileUri, otherSketchFileUris, additionalFileUris } = sketch; return [mainFileUri, ...otherSketchFileUris, ...additionalFileUris]; } const primitiveProps: Array<keyof Sketch> = ['name', 'uri', 'mainFileUri']; const arrayProps: Array<keyof Sketch> = [ 'additionalFileUris', 'otherSketchFileUris', 'rootFolderFileUris', ]; export function sameAs(left: Sketch, right: Sketch): boolean { for (const prop of primitiveProps) { const leftValue = left[prop]; const rightValue = right[prop]; assertIsNotArray(leftValue, prop, left); assertIsNotArray(rightValue, prop, right); if (leftValue !== rightValue) { return false; } } for (const prop of arrayProps) { const leftValue = left[prop]; const rightValue = right[prop]; assertIsArray(leftValue, prop, left); assertIsArray(rightValue, prop, right); if (leftValue.length !== rightValue.length) { return false; } } for (const prop of arrayProps) { const leftValue = left[prop]; const rightValue = right[prop]; assertIsArray(leftValue, prop, left); assertIsArray(rightValue, prop, right); if ( toSortedString(leftValue as string[]) !== toSortedString(rightValue as string[]) ) { return false; } } return true; } function toSortedString(array: string[]): string { return array.slice().sort().join(','); } function assertIsNotArray( toTest: unknown, prop: keyof Sketch, object: Sketch ): void { if (Array.isArray(toTest)) { throw new Error( `Expected a non-array type. Got: ${toTest}. Property was: ${prop}. Object was: ${JSON.stringify( object )}` ); } } function assertIsArray( toTest: unknown, prop: keyof Sketch, object: Sketch ): void { if (!Array.isArray(toTest)) { throw new Error( `Expected an array type. Got: ${toTest}. Property was: ${prop}. Object was: ${JSON.stringify( object )}` ); } } } export interface SketchContainer { readonly label: string; readonly children: SketchContainer[]; readonly sketches: SketchRef[]; } export namespace SketchContainer { export function create(label: string): SketchContainer { return { label, children: [], sketches: [], }; } export function is(arg: any): arg is SketchContainer { return ( !!arg && 'label' in arg && typeof arg.label === 'string' && 'children' in arg && Array.isArray(arg.children) && 'sketches' in arg && Array.isArray(arg.sketches) ); } /** * `false` if the `container` recursively contains at least one sketch. Otherwise, `true`. */ export function isEmpty(container: SketchContainer): boolean { const hasSketch = (parent: SketchContainer) => { if ( parent.sketches.length || parent.children.some((child) => hasSketch(child)) ) { return true; } return false; }; return !hasSketch(container); } export function prune<T extends SketchContainer>(container: T): T { for (let i = container.children.length - 1; i >= 0; i--) { if (isEmpty(container.children[i])) { container.children.splice(i, 1); } } return container; } export function toArray(container: SketchContainer): SketchRef[] { const visit = (parent: SketchContainer, toPushSketch: SketchRef[]) => { toPushSketch.push(...parent.sketches); parent.children.map((child) => visit(child, toPushSketch)); }; const sketches: Sketch[] = []; visit(container, sketches); return sketches; } }