diff --git a/README.md b/README.md index c6afd852f..b0e0c8a20 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ - [Options](#options) - [`--details`](#--details) - [`--format`](#--format) + - [`--fix`](#--fix) - [`--ignore-pattern`](#--ignore-pattern) - [`--config`](#--config) - [`--ui5-config`](#--ui5-config) @@ -151,6 +152,15 @@ Choose the output format. Currently, `stylish` (default), `json` and `markdown` ui5lint --format json ``` +#### `--fix` + +Automatically fix linter findings + +**Example:** +```sh +ui5lint --fix +``` + #### `--ignore-pattern` Pattern/files that will be ignored during linting. Can also be defined in `ui5lint.config.js`. diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index fdf24e997..92bce3d48 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -21,6 +21,7 @@ "globals": "^16.0.0", "he": "^1.2.0", "json-source-map": "^0.6.1", + "magic-string": "^0.30.17", "minimatch": "^10.0.1", "sax-wasm": "^3.0.5", "typescript": "^5.8.2", @@ -8359,7 +8360,6 @@ "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", - "dev": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } diff --git a/package.json b/package.json index 808b44864..39261b564 100644 --- a/package.json +++ b/package.json @@ -43,8 +43,9 @@ "prepare": "node ./.husky/skip.js || husky", "test": "npm run lint && npm run build-test && npm run coverage && npm run e2e && npm run depcheck && npm run check-licenses", "unit": "ava", - "e2e": "npm run build && npm run e2e:ui5lint && npm run e2e:test", - "e2e:ui5lint": "TEST_E2E_TMP=$PWD/test/tmp/e2e && npm run clean-test-tmp && mkdir -p $TEST_E2E_TMP && cd test/fixtures/linter/projects/com.ui5.troublesome.app && npm exec ui5lint -- --format=json > $TEST_E2E_TMP/ui5lint-results.json 2> $TEST_E2E_TMP/stderr.log || true", + "e2e": "npm run clean-test-tmp && npm run build && npm run e2e:ui5lint && npm run e2e:ui5lint-fix && npm run e2e:test", + "e2e:ui5lint": "TEST_E2E_TMP=$PWD/test/tmp/e2e && mkdir -p $TEST_E2E_TMP && cd test/fixtures/linter/projects/com.ui5.troublesome.app && npm exec ui5lint -- --format=json > $TEST_E2E_TMP/ui5lint-results.json 2> $TEST_E2E_TMP/stderr.log || true", + "e2e:ui5lint-fix": "TEST_E2E_TMP=$PWD/test/tmp/e2e && mkdir -p $TEST_E2E_TMP && cp -r test/fixtures/linter/projects/com.ui5.troublesome.app $TEST_E2E_TMP && cd $TEST_E2E_TMP/com.ui5.troublesome.app && npm exec ui5lint -- --fix --format=json > $TEST_E2E_TMP/ui5lint-results-fix.json 2> $TEST_E2E_TMP/stderr-fix.log || true", "e2e:test": "ava --config ava-e2e.config.js", "e2e:test-update-snapshots": "ava --config ava-e2e.config.js --update-snapshots", "unit-debug": "ava debug", @@ -81,6 +82,7 @@ "globals": "^16.0.0", "he": "^1.2.0", "json-source-map": "^0.6.1", + "magic-string": "^0.30.17", "minimatch": "^10.0.1", "sax-wasm": "^3.0.5", "typescript": "^5.8.2", diff --git a/src/autofix/autofix.ts b/src/autofix/autofix.ts new file mode 100644 index 000000000..02eb0870c --- /dev/null +++ b/src/autofix/autofix.ts @@ -0,0 +1,248 @@ +import ts from "typescript"; +import MagicString from "magic-string"; +import LinterContext, {RawLintMessage, ResourcePath} from "../linter/LinterContext.js"; +import {MESSAGE} from "../linter/messages.js"; +import {ModuleDeclaration} from "../linter/ui5Types/amdTranspiler/parseModuleDeclaration.js"; +import generateSolutionNoGlobals from "./solutions/noGlobals.js"; +import {getLogger} from "@ui5/logger"; +import {addDependencies} from "./solutions/amdImports.js"; + +const log = getLogger("linter:autofix"); + +export interface AutofixResource { + content: string; + messages: RawLintMessage[]; +} + +export interface AutofixOptions { + rootDir: string; + namespace?: string; + resources: Map; + context: LinterContext; +} + +export enum ChangeAction { + INSERT = "insert", + REPLACE = "replace", + DELETE = "delete", +} + +export type ChangeSet = InsertChange | ReplaceChange | DeleteChange; + +interface AbstractChangeSet { + action: ChangeAction; + start: number; +} + +interface InsertChange extends AbstractChangeSet { + action: ChangeAction.INSERT; + value: string; +} + +interface ReplaceChange extends AbstractChangeSet { + action: ChangeAction.REPLACE; + end: number; + value: string; +} + +interface DeleteChange extends AbstractChangeSet { + action: ChangeAction.DELETE; + end: number; +} + +export type AutofixResult = Map; +type SourceFiles = Map; + +interface Position { + line: number; + column: number; + pos: number; +} +export interface GlobalPropertyAccessNodeInfo { + globalVariableName: string; + namespace: string; + moduleName: string; + exportName?: string; + propertyAccess?: string; + position: Position; + node?: ts.Identifier | ts.PropertyAccessExpression | ts.ElementAccessExpression; +} + +export interface DeprecatedApiAccessNode { + apiName: string; + position: Position; + node?: ts.CallExpression | ts.Identifier | ts.PropertyAccessExpression | ts.ElementAccessExpression; +} + +export type ImportRequests = Map; + +// type ModuleDeclarationInfo = ExistingModuleDeclarationInfo | NewModuleDeclarationInfo; + +export interface ExistingModuleDeclarationInfo { + moduleDeclaration: ModuleDeclaration; + importRequests: ImportRequests; +} + +export interface NewModuleDeclarationInfo { + declareCall: ts.CallExpression; + requireCalls: Map; + importRequests: ImportRequests; + endPos?: number; +} + +function createCompilerHost(sourceFiles: SourceFiles): ts.CompilerHost { + return { + getSourceFile: (fileName) => sourceFiles.get(fileName), + writeFile: () => undefined, + getDefaultLibFileName: () => "lib.d.ts", + useCaseSensitiveFileNames: () => false, + getCanonicalFileName: (fileName) => fileName, + getCurrentDirectory: () => "", + getNewLine: () => "\n", + fileExists: (fileName): boolean => sourceFiles.has(fileName), + readFile: () => "", + directoryExists: () => true, + getDirectories: () => [], + }; +} + +const compilerOptions: ts.CompilerOptions = { + checkJs: false, + allowJs: true, + skipLibCheck: true, + noCheck: true, + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ES2022, + isolatedModules: true, + sourceMap: true, + suppressOutputPathCheck: true, + noLib: true, + noResolve: true, + allowNonTsExtensions: true, +}; + +function createProgram(inputFileNames: string[], host: ts.CompilerHost): ts.Program { + return ts.createProgram(inputFileNames, compilerOptions, host); +} + +function getJsErrors(code: string, resourcePath: string) { + const sourceFile = ts.createSourceFile( + resourcePath, + code, + ts.ScriptTarget.ES2022, + true, + ts.ScriptKind.JS + ); + + const host = createCompilerHost(new Map([[resourcePath, sourceFile]])); + const program = createProgram([resourcePath], host); + const diagnostics = ts.getPreEmitDiagnostics(program, sourceFile); + + return diagnostics.filter(function (d) { + return d.file === sourceFile && d.category === ts.DiagnosticCategory.Error; + }); +} + +// eslint-disable-next-line @typescript-eslint/require-await +export default async function ({ + rootDir: _unused1, + namespace: _unused2, + resources, + context, +}: AutofixOptions): Promise { + const sourceFiles: SourceFiles = new Map(); + const resourcePaths = []; + for (const [resourcePath, resource] of resources) { + const sourceFile = ts.createSourceFile( + resourcePath, + resource.content, + { + languageVersion: ts.ScriptTarget.ES2022, + jsDocParsingMode: ts.JSDocParsingMode.ParseNone, + } + ); + sourceFiles.set(resourcePath, sourceFile); + resourcePaths.push(resourcePath); + } + + const compilerHost = createCompilerHost(sourceFiles); + const program = createProgram(resourcePaths, compilerHost); + + const checker = program.getTypeChecker(); + const res: AutofixResult = new Map(); + for (const [resourcePath, sourceFile] of sourceFiles) { + log.verbose(`Applying autofixes to ${resourcePath}`); + const newContent = applyFixes(checker, sourceFile, resourcePath, resources.get(resourcePath)!); + if (newContent) { + const jsErrors = getJsErrors(newContent, resourcePath); + if (jsErrors.length) { + const message = `Syntax error after applying autofix for '${resourcePath}': ` + + jsErrors.map((d) => d.messageText as string).join(", "); + log.verbose(message); + context.addLintingMessage(resourcePath, MESSAGE.PARSING_ERROR, {message}); + } else { + res.set(resourcePath, newContent); + } + } + } + + return res; +} + +function applyFixes( + checker: ts.TypeChecker, sourceFile: ts.SourceFile, resourcePath: ResourcePath, + resource: AutofixResource +): string | undefined { + const {content} = resource; + + // Group messages by id + const messagesById = new Map(); + for (const msg of resource.messages) { + if (!messagesById.has(msg.id)) { + messagesById.set(msg.id, []); + } + messagesById.get(msg.id)!.push(msg); + } + + const changeSet: ChangeSet[] = []; + let existingModuleDeclarations = new Map(); + if (messagesById.has(MESSAGE.NO_GLOBALS)) { + existingModuleDeclarations = generateSolutionNoGlobals( + checker, sourceFile, content, + messagesById.get(MESSAGE.NO_GLOBALS) as RawLintMessage[], + changeSet, []); + } + + for (const [defineCall, moduleDeclarationInfo] of existingModuleDeclarations) { + addDependencies(defineCall, moduleDeclarationInfo, changeSet); + } + + if (changeSet.length === 0) { + // No modifications needed + return undefined; + } + return applyChanges(content, changeSet); +} + +function applyChanges(content: string, changeSet: ChangeSet[]): string { + changeSet.sort((a, b) => a.start - b.start); + const s = new MagicString(content); + + for (const change of changeSet) { + switch (change.action) { + case ChangeAction.INSERT: + s.appendRight(change.start, change.value); + break; + case ChangeAction.REPLACE: + s.update(change.start, change.end, change.value); + break; + case ChangeAction.DELETE: + s.remove(change.start, change.end); + break; + } + } + return s.toString(); +} diff --git a/src/autofix/solutions/amdImports.ts b/src/autofix/solutions/amdImports.ts new file mode 100644 index 000000000..43362c65b --- /dev/null +++ b/src/autofix/solutions/amdImports.ts @@ -0,0 +1,165 @@ +import ts from "typescript"; +import {ChangeAction, ImportRequests, ChangeSet, ExistingModuleDeclarationInfo} from "../autofix.js"; +import {collectModuleIdentifiers} from "../utils.js"; +import {resolveUniqueName} from "../../linter/ui5Types/utils/utils.js"; + +export function addDependencies( + defineCall: ts.CallExpression, moduleDeclarationInfo: ExistingModuleDeclarationInfo, + changeSet: ChangeSet[] +) { + const {moduleDeclaration, importRequests} = moduleDeclarationInfo; + + if (importRequests.size === 0) { + return; + } + + const declaredIdentifiers = collectModuleIdentifiers(moduleDeclaration.factory); + + const defineCallArgs = defineCall.arguments; + const existingImportModules = defineCall.arguments && ts.isArrayLiteralExpression(defineCallArgs[0]) ? + defineCallArgs[0].elements.map((el) => ts.isStringLiteralLike(el) ? el.text : "") : + []; + + if (!ts.isFunctionLike(moduleDeclaration.factory)) { + throw new Error("Invalid factory function"); + } + const existingIdentifiers = moduleDeclaration.factory + .parameters.map((param: ts.ParameterDeclaration) => (param.name as ts.Identifier).text); + const existingIdentifiersLength = existingIdentifiers.length; + + const imports = [...importRequests.keys()]; + + const identifiersForExistingImports: string[] = []; + let existingIdentifiersCut = 0; + existingImportModules.forEach((existingModule, index) => { + const indexOf = imports.indexOf(existingModule); + const identifierName = existingIdentifiers[index] || + resolveUniqueName(existingModule, declaredIdentifiers); + declaredIdentifiers.add(identifierName); + identifiersForExistingImports.push(identifierName); + if (indexOf !== -1) { + // If there are defined dependencies, but identifiers for them are missing, + // and those identifiers are needed in the code, then we need to find out + // up to which index we need to build identifiers and cut the rest. + existingIdentifiersCut = index > existingIdentifiersCut ? (index + 1) : existingIdentifiersCut; + imports.splice(indexOf, 1); + importRequests.get(existingModule)!.identifier = identifierName; + } + }); + + // Cut identifiers that are already there + identifiersForExistingImports.splice(existingIdentifiersCut); + + const dependencies = imports.map((i) => `"${i}"`); + const identifiers = [ + ...identifiersForExistingImports, + ...imports.map((i) => { + const identifier = resolveUniqueName(i, declaredIdentifiers); + declaredIdentifiers.add(identifier); + importRequests.get(i)!.identifier = identifier; + return identifier; + })]; + + if (dependencies.length) { + // Add dependencies + if (moduleDeclaration.dependencies) { + const depsNode = defineCall.arguments[0]; + const depElementNodes = depsNode && ts.isArrayLiteralExpression(depsNode) ? depsNode.elements : []; + const insertAfterElement = depElementNodes[existingIdentifiersLength - 1] ?? + depElementNodes[depElementNodes.length - 1]; + + if (insertAfterElement) { + changeSet.push({ + action: ChangeAction.INSERT, + start: insertAfterElement.getEnd(), + value: (existingImportModules.length ? ", " : "") + dependencies.join(", "), + }); + } else { + changeSet.push({ + action: ChangeAction.REPLACE, + start: depsNode.getFullStart(), + end: depsNode.getEnd(), + value: `[${dependencies.join(", ")}]`, + }); + } + } else { + changeSet.push({ + action: ChangeAction.INSERT, + start: defineCall.arguments[0].getFullStart(), + value: `[${dependencies.join(", ")}], `, + }); + } + } + + if (identifiers.length) { + const closeParenToken = moduleDeclaration.factory.getChildren() + .find((c) => c.kind === ts.SyntaxKind.CloseParenToken); + // Factory arguments + const syntaxList = moduleDeclaration.factory.getChildren() + .find((c) => c.kind === ts.SyntaxKind.SyntaxList); + if (!syntaxList) { + throw new Error("Invalid factory syntax"); + } + + // Patch factory arguments + const value = (existingIdentifiersLength ? ", " : "") + identifiers.join(", "); + if (!closeParenToken) { + changeSet.push({ + action: ChangeAction.INSERT, + start: syntaxList.getStart(), + value: "(", + }); + changeSet.push({ + action: ChangeAction.INSERT, + start: syntaxList.getEnd(), + value: `${value})`, + }); + } else { + let start = syntaxList.getEnd(); + + // Existing trailing comma: Insert new args before it, to keep the trailing comma + const lastSyntaxListChild = syntaxList.getChildAt(syntaxList.getChildCount() - 1); + if (lastSyntaxListChild?.kind === ts.SyntaxKind.CommaToken) { + start = lastSyntaxListChild.getStart(); + } + + changeSet.push({ + action: ChangeAction.INSERT, + start, + value, + }); + } + } + + // Patch identifiers + patchIdentifiers(importRequests, changeSet); +} + +function patchIdentifiers(importRequests: ImportRequests, changeSet: ChangeSet[]) { + for (const {nodeInfos, identifier} of importRequests.values()) { + if (!identifier) { + throw new Error("No identifier found for import"); + } + + for (const nodeInfo of nodeInfos) { + if (!nodeInfo.node) { + continue; + } + let node: ts.Node = nodeInfo.node; + + if ("namespace" in nodeInfo && nodeInfo.namespace === "sap.ui.getCore") { + node = node.parent; + } + const nodeStart = node.getStart(); + const nodeEnd = node.getEnd(); + const nodeReplacement = `${identifier}`; + + changeSet.push({ + action: ChangeAction.REPLACE, + start: nodeStart, + end: nodeEnd, + value: nodeReplacement, + }); + } + } +} diff --git a/src/autofix/solutions/noGlobals.ts b/src/autofix/solutions/noGlobals.ts new file mode 100644 index 000000000..b80ae0ecf --- /dev/null +++ b/src/autofix/solutions/noGlobals.ts @@ -0,0 +1,133 @@ +import ts from "typescript"; +import type {RawLintMessage} from "../../linter/LinterContext.js"; +import {MESSAGE} from "../../linter/messages.js"; +import type { + ChangeSet, + ExistingModuleDeclarationInfo, + GlobalPropertyAccessNodeInfo, + NewModuleDeclarationInfo, +} from "../autofix.js"; +import {findGreatestAccessExpression, matchPropertyAccessExpression} from "../utils.js"; +import parseModuleDeclaration from "../../linter/ui5Types/amdTranspiler/parseModuleDeclaration.js"; +import {getLogger} from "@ui5/logger"; + +const log = getLogger("linter:autofix:NoGlobals"); + +export default function generateSolutionNoGlobals( + checker: ts.TypeChecker, sourceFile: ts.SourceFile, content: string, + messages: RawLintMessage[], + changeSet: ChangeSet[], newModuleDeclarations: NewModuleDeclarationInfo[] +) { + // Collect all global property access nodes + const affectedNodesInfo = new Set(); + for (const msg of messages) { + if (!msg.position) { + throw new Error(`Unable to produce solution for message without position`); + } + if (!msg.args.fixHints.moduleName) { + // Skip global access without module name + continue; + } + // TypeScript lines and columns are 0-based + const line = msg.position.line - 1; + const column = msg.position.column - 1; + const pos = sourceFile.getPositionOfLineAndCharacter(line, column); + affectedNodesInfo.add({ + globalVariableName: msg.args.variableName, + namespace: msg.args.namespace, + moduleName: msg.args.fixHints.moduleName, + exportName: msg.args.fixHints.exportName, + propertyAccess: msg.args.fixHints.propertyAccess, + position: { + line, + column, + pos, + }, + }); + } + + const sapUiDefineCalls: ts.CallExpression[] = []; + function visitNode(node: ts.Node) { + for (const nodeInfo of affectedNodesInfo) { + if (node.getStart() === nodeInfo.position.pos) { + if (!ts.isIdentifier(node)) { + continue; + // throw new Error(`Expected node to be an Identifier but got ${ts.SyntaxKind[node.kind]}`); + } + nodeInfo.node = findGreatestAccessExpression(node, nodeInfo.propertyAccess); + } + } + + if (ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression)) { + if (matchPropertyAccessExpression(node.expression, "sap.ui.define")) { + sapUiDefineCalls.push(node); + } + } + ts.forEachChild(node, visitNode); + } + ts.forEachChild(sourceFile, visitNode); + for (const nodeInfo of affectedNodesInfo) { + if (!nodeInfo.node) { + throw new Error(`Unable to find node for ${nodeInfo.globalVariableName}`); + } + } + + const moduleDeclarations = new Map(); + + for (const nodeInfo of affectedNodesInfo) { + const {moduleName, position} = nodeInfo; + // Find relevant sap.ui.define call + let defineCall: ts.CallExpression | undefined | null; + if (sapUiDefineCalls.length === 1) { + defineCall = sapUiDefineCalls[0]; + } else if (sapUiDefineCalls.length > 1) { + for (const sapUiDefineCall of sapUiDefineCalls) { + if (sapUiDefineCall.getStart() < position.pos) { + defineCall = sapUiDefineCall; + } + } + } + if (defineCall === undefined) { + defineCall = null; + } + let moduleDeclaration; + if (defineCall) { + if (!moduleDeclarations.has(defineCall)) { + try { + moduleDeclarations.set(defineCall, { + moduleDeclaration: parseModuleDeclaration(defineCall.arguments, checker), + importRequests: new Map(), + }); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + log.verbose(`Failed to autofix ${moduleName} in sap.ui.define ` + + `call in ${sourceFile.fileName}: ${errorMessage}`); + } + } + moduleDeclaration = moduleDeclarations.get(defineCall)!; + } else { + if (!newModuleDeclarations.length) { + // throw new Error(`TODO: Implement handling for global access without module declaration`); + } + for (const decl of newModuleDeclarations) { + if (position.pos > decl.declareCall.getStart()) { + moduleDeclaration = decl; + } else { + break; + } + } + } + if (!moduleDeclaration) { + // throw new Error(`TODO: Implement handling for global access without module declaration`); + } + if (moduleDeclaration && !moduleDeclaration.importRequests.has(moduleName)) { + moduleDeclaration.importRequests.set(moduleName, { + nodeInfos: [], + }); + } + moduleDeclaration?.importRequests.get(moduleName)!.nodeInfos.push(nodeInfo); + } + + return moduleDeclarations; +} diff --git a/src/autofix/utils.ts b/src/autofix/utils.ts new file mode 100644 index 000000000..b4fe597c8 --- /dev/null +++ b/src/autofix/utils.ts @@ -0,0 +1,93 @@ +import ts from "typescript"; +import {getPropertyNameText} from "../linter/ui5Types/utils/utils.js"; + +export function findGreatestAccessExpression(node: ts.Identifier, matchPropertyAccess?: string): + ts.Identifier | ts.PropertyAccessExpression | ts.ElementAccessExpression { + type Candidate = ts.Identifier | ts.PropertyAccessExpression | ts.ElementAccessExpression; + let scanNode: Candidate = node; + let propertyAccessChain: string[] = []; + if (matchPropertyAccess) { + propertyAccessChain = matchPropertyAccess.split("."); + if (node.text !== "window") { + const firstPropAccess = propertyAccessChain.shift(); + if (node.text !== firstPropAccess) { + throw new Error(`Expected node to be ${firstPropAccess} but got ${node.getText()}`); + } + } + } + while (ts.isPropertyAccessExpression(scanNode.parent) || ts.isElementAccessExpression(scanNode.parent)) { + scanNode = scanNode.parent; + if (matchPropertyAccess) { + const nextPropertyAccess = propertyAccessChain.shift(); + + let propName; + if (ts.isPropertyAccessExpression(scanNode)) { + propName = getPropertyNameText(scanNode.name); + } else { + if ( + ts.isStringLiteralLike(scanNode.argumentExpression) || + ts.isNumericLiteral(scanNode.argumentExpression) + ) { + propName = scanNode.argumentExpression.text; + } else { + propName = scanNode.argumentExpression.getText(); + } + } + if (propName !== nextPropertyAccess) { + throw new Error(`Expected node to be ${nextPropertyAccess} but got ${propName}`); + } + if (!propertyAccessChain.length) { + return scanNode; + } + } + } + return scanNode; +} + +export function matchPropertyAccessExpression(node: ts.PropertyAccessExpression, match: string): boolean { + const propAccessChain: string[] = []; + propAccessChain.push(node.expression.getText()); + + let scanNode: ts.Node = node; + while (ts.isPropertyAccessExpression(scanNode)) { + propAccessChain.push(scanNode.name.getText()); + scanNode = scanNode.parent; + } + return propAccessChain.join(".") === match; +} + +export function collectModuleIdentifiers(moduleDeclaration: ts.Node) { + const declaredIdentifiers = new Set(); + const extractDestructIdentifiers = (name: ts.BindingName, identifiers: Set) => { + if (ts.isIdentifier(name)) { + identifiers.add(name.text); + } else if (ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name)) { + for (const element of name.elements) { + if (ts.isBindingElement(element)) { + extractDestructIdentifiers(element.name, identifiers); + } + } + } + }; + const collectIdentifiers = (node: ts.Node) => { + if ( + ts.isVariableDeclaration(node) || + ts.isFunctionDeclaration(node) || + ts.isClassDeclaration(node) + ) { + if (node.name && ts.isIdentifier(node.name)) { + declaredIdentifiers.add(node.name.text); + } + } + + if (ts.isParameter(node) || ts.isVariableDeclaration(node)) { + extractDestructIdentifiers(node.name, declaredIdentifiers); + } + + ts.forEachChild(node, collectIdentifiers); + }; + + ts.forEachChild(moduleDeclaration, collectIdentifiers); + + return declaredIdentifiers; +} diff --git a/src/cli/base.ts b/src/cli/base.ts index 742d3ae57..386e37398 100644 --- a/src/cli/base.ts +++ b/src/cli/base.ts @@ -17,6 +17,7 @@ export interface LinterArg { filePaths?: string[]; ignorePattern?: string[]; details: boolean; + fix: boolean; format: string; config?: string; ui5Config?: string; @@ -71,6 +72,11 @@ const lintCommand: FixedCommandModule = { type: "boolean", default: false, }) + .option("fix", { + describe: "Automatically fix linter findings", + type: "boolean", + default: false, + }) .option("loglevel", { alias: "log-level", describe: "Set the logging level", @@ -137,6 +143,7 @@ async function handleLint(argv: ArgumentsCamelCase) { coverage, ignorePattern: ignorePatterns, details, + fix, format, config, ui5Config, @@ -158,6 +165,7 @@ async function handleLint(argv: ArgumentsCamelCase) { filePatterns, coverage: reportCoverage, details, + fix, config, ui5Config, }); diff --git a/src/index.ts b/src/index.ts index 66e6d201c..acca3a12a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,11 @@ export interface UI5LinterOptions { * @default false */ details?: boolean; + /** + * Automatically fix linter findings + * @default false + */ + fix?: boolean; /** * Path to a ui5lint.config.(cjs|mjs|js) file */ @@ -64,6 +69,7 @@ export class UI5LinterEngine { filePatterns, ignorePatterns = [], details = false, + fix = false, config, noConfig, coverage = false, @@ -78,6 +84,7 @@ export class UI5LinterEngine { ignorePatterns, coverage, details, + fix, configPath: config, noConfig, ui5Config, diff --git a/src/linter/LinterContext.ts b/src/linter/LinterContext.ts index 67d5800d3..fa2957513 100644 --- a/src/linter/LinterContext.ts +++ b/src/linter/LinterContext.ts @@ -21,6 +21,11 @@ export interface LintResult { warningCount: number; } +export interface RawLintResult { + filePath: FilePath; + rawMessages: RawLintMessage[]; +} + export interface RawLintMessage { id: M; args: MessageArgs[M]; @@ -64,19 +69,13 @@ export interface LinterOptions { ignorePatterns?: FilePattern[]; coverage?: boolean; details?: boolean; + fix?: boolean; configPath?: string; noConfig?: boolean; ui5Config?: string | object; namespace?: string; } -export interface FSToVirtualPathOptions { - relFsBasePath: string; - virBasePath: string; - relFsBasePathTest?: string; - virBasePathTest?: string; -}; - export interface LinterParameters { workspace: AbstractAdapter; filePathsWorkspace: AbstractAdapter; @@ -371,4 +370,23 @@ export default class LinterContext { return lintResults; } + + generateRawLintResults(): RawLintResult[] { + const rawLintResults: RawLintResult[] = []; + let resourcePaths; + if (this.#reportCoverage) { + resourcePaths = new Set([...this.#rawMessages.keys(), ...this.#coverageInfo.keys()]).values(); + } else { + resourcePaths = this.#rawMessages.keys(); + } + + for (const resourcePath of resourcePaths) { + rawLintResults.push({ + filePath: resourcePath, + rawMessages: this.#getFilteredMessages(resourcePath), + }); + } + + return rawLintResults; + } } diff --git a/src/linter/binding/BindingLinter.ts b/src/linter/binding/BindingLinter.ts index 568abff38..783561a2f 100644 --- a/src/linter/binding/BindingLinter.ts +++ b/src/linter/binding/BindingLinter.ts @@ -157,6 +157,7 @@ export default class BindingLinter { this.#context.addLintingMessage(this.#resourcePath, MESSAGE.NO_GLOBALS, { variableName, namespace: ref, + fixHints: {}, }, position); } diff --git a/src/linter/lintWorkspace.ts b/src/linter/lintWorkspace.ts index 3daffb8a8..3f65064e8 100644 --- a/src/linter/lintWorkspace.ts +++ b/src/linter/lintWorkspace.ts @@ -8,21 +8,91 @@ import lintFileTypes from "./fileTypes/linter.js"; import {taskStart} from "../utils/perf.js"; import TypeLinter from "./ui5Types/TypeLinter.js"; import LinterContext, {LintResult, LinterParameters, LinterOptions} from "./LinterContext.js"; -import {createReader} from "@ui5/fs/resourceFactory"; +import {createReader, createResource} from "@ui5/fs/resourceFactory"; import {mergeIgnorePatterns, resolveReader} from "./linter.js"; import {UI5LintConfigType} from "../utils/ConfigManager.js"; import type SharedLanguageService from "./ui5Types/SharedLanguageService.js"; -import {FSToVirtualPathOptions} from "../utils/virtualPathToFilePath.js"; +import autofix, {AutofixResource} from "../autofix/autofix.js"; +import {writeFile} from "node:fs/promises"; +import {FSToVirtualPathOptions, transformVirtualPathToFilePath} from "../utils/virtualPathToFilePath.js"; +import {getLogger} from "@ui5/logger"; + +const log = getLogger("linter:lintWorkspace"); export default async function lintWorkspace( workspace: AbstractAdapter, filePathsWorkspace: AbstractAdapter, options: LinterOptions & FSToVirtualPathOptions, config: UI5LintConfigType, patternsMatch: Set, sharedLanguageService: SharedLanguageService ): Promise { - const context = await runLintWorkspace( + let context = await runLintWorkspace( workspace, filePathsWorkspace, options, config, patternsMatch, sharedLanguageService ); + if (options.fix) { + const rawLintResults = context.generateRawLintResults(); + const rootReader = context.getRootReader(); + + const autofixResources = new Map(); + for (const {filePath, rawMessages} of rawLintResults) { + // FIXME: handle this the same way as we already do for the general results + let resource = await workspace.byPath(filePath); + if (!resource) { + resource = await rootReader.byPath(filePath); + } + const content = await resource.getString(); + autofixResources.set(filePath, { + content, + messages: rawMessages, + }); + // patternsMatch.add(filePath); // Eventually filter files that have been autofixed + } + + log.verbose(`Autofixing ${autofixResources.size} files...`); + const doneAutofix = taskStart("Autofix"); + + const autofixResult = await autofix({ + rootDir: options.rootDir, + namespace: options.namespace, + resources: autofixResources, + context, + }); + + doneAutofix(); + + log.verbose(`Autofix provided solutions for ${autofixResult.size} files`); + + if (autofixResult.size > 0) { + for (const [filePath, content] of autofixResult.entries()) { + const newResource = createResource({ + path: filePath, + string: content, + }); + await workspace.write(newResource); + await filePathsWorkspace.write(newResource); + sharedLanguageService.setScriptVersion(filePath, sharedLanguageService.getScriptVersion(filePath) + 1); + } + + log.verbose("Linting again after applying fixes..."); + + // Run lint again after fixes are applied + context = await runLintWorkspace( + workspace, filePathsWorkspace, options, config, patternsMatch, sharedLanguageService + ); + + // Update fixed files on the filesystem + if (process.env.UI5LINT_FIX_DRY_RUN) { + log.verbose("UI5LINT_FIX_DRY_RUN: Not updating files on the filesystem"); + } else { + const autofixFiles = Array.from(autofixResult.entries()); + await Promise.all(autofixFiles.map(async ([filePath, content]) => { + const realFilePath = transformVirtualPathToFilePath(filePath, options); + log.verbose(`Writing fixed file '${filePath}' to '${realFilePath}'`); + await writeFile(realFilePath, content); + })); + } + } + } + return context.generateLintResults(); } diff --git a/src/linter/linter.ts b/src/linter/linter.ts index 5bb86872e..4fc357455 100644 --- a/src/linter/linter.ts +++ b/src/linter/linter.ts @@ -13,7 +13,7 @@ import type SharedLanguageService from "./ui5Types/SharedLanguageService.js"; import {FSToVirtualPathOptions, transformVirtualPathToFilePath} from "../utils/virtualPathToFilePath.js"; export async function lintProject({ - rootDir, filePatterns, ignorePatterns, coverage, details, configPath, ui5Config, noConfig, + rootDir, filePatterns, ignorePatterns, coverage, details, fix, configPath, ui5Config, noConfig, }: LinterOptions, sharedLanguageService: SharedLanguageService): Promise { if (!path.isAbsolute(rootDir)) { throw new Error(`rootDir must be an absolute path. Received: ${rootDir}`); @@ -71,6 +71,7 @@ export async function lintProject({ ignorePatterns, coverage, details, + fix, configPath, noConfig, ui5Config, @@ -89,7 +90,7 @@ export async function lintProject({ } export async function lintFile({ - rootDir, filePatterns, ignorePatterns, namespace, coverage, details, configPath, noConfig, + rootDir, filePatterns, ignorePatterns, namespace, coverage, details, fix, configPath, noConfig, }: LinterOptions, sharedLanguageService: SharedLanguageService ): Promise { let config: UI5LintConfigType = {}; @@ -111,6 +112,7 @@ export async function lintFile({ ignorePatterns, coverage, details, + fix, configPath, relFsBasePath: "", virBasePath, diff --git a/src/linter/messages.ts b/src/linter/messages.ts index cb1a6a699..9094d8884 100644 --- a/src/linter/messages.ts +++ b/src/linter/messages.ts @@ -437,7 +437,8 @@ export const MESSAGE_INFO = { message: ({variableName, namespace}: {variableName: string; namespace: string}) => `Access of global variable '${variableName}' (${namespace})`, - details: () => + details: ({fixHints: {moduleName: _unused1, exportName: _unused2, propertyAccess: _unused3}, + }: {fixHints: {moduleName?: string; exportName?: string; propertyAccess?: string}}) => `Do not use global variables to access UI5 modules or APIs. ` + `{@link topic:28fcd55b04654977b63dacbee0552712 See Best Practices for Developers}`, }, diff --git a/src/linter/ui5Types/SharedLanguageService.ts b/src/linter/ui5Types/SharedLanguageService.ts index eb09aa310..3428af6be 100644 --- a/src/linter/ui5Types/SharedLanguageService.ts +++ b/src/linter/ui5Types/SharedLanguageService.ts @@ -1,11 +1,15 @@ import ts from "typescript"; import LanguageServiceHostProxy from "./LanguageServiceHostProxy.js"; +import {getLogger} from "@ui5/logger"; + +const log = getLogger("linter:SharedLanguageService"); export default class SharedLanguageService { private readonly languageServiceHostProxy: LanguageServiceHostProxy; private readonly languageService: ts.LanguageService; private acquired = false; private projectScriptVersion = 0; + private fileScriptVersions = new Map(); constructor() { this.languageServiceHostProxy = new LanguageServiceHostProxy(); @@ -45,6 +49,14 @@ export default class SharedLanguageService { this.acquired = false; } + setScriptVersion(fileName: string, version: number) { + return this.fileScriptVersions.set(fileName, version); + } + + getScriptVersion(fileName: string) { + return this.fileScriptVersions.get(fileName) ?? this.projectScriptVersion; + } + getNextProjectScriptVersion() { this.projectScriptVersion++; return this.projectScriptVersion.toString(); diff --git a/src/linter/ui5Types/SourceFileLinter.ts b/src/linter/ui5Types/SourceFileLinter.ts index 070bb61a3..ed20ecf93 100644 --- a/src/linter/ui5Types/SourceFileLinter.ts +++ b/src/linter/ui5Types/SourceFileLinter.ts @@ -13,6 +13,7 @@ import { getPropertyAssignmentsInObjectLiteralExpression, findClassMember, isClassMethod, + isGlobalAssignment, } from "./utils/utils.js"; import {taskStart} from "../../utils/perf.js"; import {getPositionsForNode} from "../../utils/nodePosition.js"; @@ -1413,6 +1414,7 @@ export default class SourceFileLinter { this.#reporter.addMessage(MESSAGE.NO_GLOBALS, { variableName: node.text, namespace: moduleName, + fixHints: {}, }, node); } } @@ -1605,9 +1607,18 @@ export default class SourceFileLinter { if (symbol && this.isSymbolOfUi5OrThirdPartyType(symbol) && !((ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) && this.isAllowedPropertyAccess(node))) { + const namespace = this.extractNamespace((node as ts.PropertyAccessExpression)); + + const fixable = ts.isCallExpression(node) || !isGlobalAssignment(node); + let fixHints = {}; + if (fixable) { + fixHints = this.getImportFromGlobal(namespace); + } + this.#reporter.addMessage(MESSAGE.NO_GLOBALS, { variableName: symbol.getName(), - namespace: this.extractNamespace((node as ts.PropertyAccessExpression)), + namespace, + fixHints, }, node); } } @@ -1795,4 +1806,62 @@ export default class SourceFileLinter { hasQUnitFileExtension() { return QUNIT_FILE_EXTENSION.test(this.sourceFile.fileName); } + + findModuleForName(moduleName: string): ts.Symbol | undefined { + const moduleSymbol = this.ambientModuleCache.getModule(moduleName); + + if (!moduleSymbol) { + return; + } + const declarations = moduleSymbol.getDeclarations(); + if (!declarations) { + throw new Error(`Could not find declarations for module: ${moduleName}`); + } + for (const decl of declarations) { + const sourceFile = decl.getSourceFile(); + if (isSourceFileOfTypeScriptLib(sourceFile)) { + // Ignore any non-UI5 symbols + return; + } + if (isSourceFileOfPseudoModuleType(sourceFile)) { + // Ignore pseudo modules, we rather find them via probing for the library module + return; + } + } + return moduleSymbol; + } + + getImportFromGlobal(namespace: string): {moduleName?: string; exportName?: string; propertyAccess?: string} { + if (namespace === "jQuery") { + return {moduleName: "sap/ui/thirdparty/jquery"}; + } + namespace = namespace.replace(/^(?:window|globalThis|self)./, ""); + let moduleSymbol; + const parts = namespace.split("."); + const searchStack = [...parts]; + let exportName; + while (!moduleSymbol && searchStack.length) { + const moduleName = searchStack.join("/"); + moduleSymbol = this.findModuleForName(moduleName); + if (!moduleSymbol) { + const libraryModuleName = `${moduleName}/library`; + moduleSymbol = this.findModuleForName(libraryModuleName); + if (moduleSymbol) { + exportName = parts[searchStack.length]; + if (exportName && !moduleSymbol.exports?.has(exportName as ts.__String)) { + // throw new Error(`Could not find export ${exportName} in module: ${namespace}`); + return {}; + } + return {moduleName: libraryModuleName, exportName, propertyAccess: searchStack.join(".")}; + } + } + if (!moduleSymbol) { + searchStack.pop(); + } + } + if (!searchStack.length) { + return {}; + } + return {moduleName: searchStack.join("/"), exportName, propertyAccess: searchStack.join(".")}; + } } diff --git a/src/linter/ui5Types/TypeLinter.ts b/src/linter/ui5Types/TypeLinter.ts index 3f4522be0..f410107a1 100644 --- a/src/linter/ui5Types/TypeLinter.ts +++ b/src/linter/ui5Types/TypeLinter.ts @@ -109,10 +109,8 @@ export default class TypeLinter { } } - const projectScriptVersion = this.#sharedLanguageService.getNextProjectScriptVersion(); - const host = await createVirtualLanguageServiceHost( - this.#compilerOptions, files, this.#sourceMaps, this.#context, projectScriptVersion + this.#compilerOptions, files, this.#sourceMaps, this.#context, this.#sharedLanguageService ); this.#sharedLanguageService.acquire(host); diff --git a/src/linter/ui5Types/amdTranspiler/pruneNode.ts b/src/linter/ui5Types/amdTranspiler/pruneNode.ts index e704d49dc..aebe8b726 100644 --- a/src/linter/ui5Types/amdTranspiler/pruneNode.ts +++ b/src/linter/ui5Types/amdTranspiler/pruneNode.ts @@ -21,11 +21,13 @@ export class UnsafeNodeRemoval extends Error { */ export default function (node: ts.Node) { let nodeToRemove: ts.Node | undefined = node; + let greatestRemovableNode: ts.Node | undefined = undefined; try { while (nodeToRemove) { // Attempt to prune the node, if the parent can exist without it if (pruneNode(nodeToRemove)) { nodeToRemove = nodeToRemove.parent; + greatestRemovableNode = nodeToRemove; } else { nodeToRemove = undefined; } @@ -38,6 +40,7 @@ export default function (node: ts.Node) { } throw err; } + return greatestRemovableNode; } /** diff --git a/src/linter/ui5Types/host.ts b/src/linter/ui5Types/host.ts index 2b1e7f534..b55001937 100644 --- a/src/linter/ui5Types/host.ts +++ b/src/linter/ui5Types/host.ts @@ -6,6 +6,7 @@ import {createRequire} from "node:module"; import transpileAmdToEsm from "./amdTranspiler/transpiler.js"; import LinterContext, {ResourcePath} from "../LinterContext.js"; import {getLogger} from "@ui5/logger"; +import type SharedLanguageService from "./SharedLanguageService.js"; const log = getLogger("linter:ui5Types:host"); const require = createRequire(import.meta.url); @@ -72,7 +73,7 @@ export async function createVirtualLanguageServiceHost( options: ts.CompilerOptions, files: FileContents, sourceMaps: FileContents, context: LinterContext, - projectScriptVersion: string + sharedLanguageService: SharedLanguageService ): Promise { const silly = log.isLevelEnabled("silly"); @@ -167,17 +168,11 @@ export async function createVirtualLanguageServiceHost( }, getScriptVersion: (fileName) => { + const scriptVersion = sharedLanguageService.getScriptVersion(fileName); if (silly) { - log.silly(`getScriptVersion: ${fileName}`); + log.silly(`getScriptVersion: ${fileName} ${scriptVersion}`); } - // Note: The script version for the common files at /types/ is handled within the LanguageServiceHostProxy - - // Currently we don't use incremental compilation within a project, so - // updating the script version is not necessary. - // However, as the language service is shared across multiple projects, we need - // to provide a version that is unique for each project to avoid impacting other - // projects that might use the same file path. - return projectScriptVersion; + return scriptVersion.toString(); }, getScriptSnapshot: (fileName) => { diff --git a/src/linter/ui5Types/utils/utils.ts b/src/linter/ui5Types/utils/utils.ts index f112f4372..dcb2eb090 100644 --- a/src/linter/ui5Types/utils/utils.ts +++ b/src/linter/ui5Types/utils/utils.ts @@ -195,3 +195,18 @@ export function resolveUniqueName(inputName: string, existingIdentifiers?: Set { + const stderr = await readFile(new URL("stderr-fix.log", E2E_DIR_URL), {encoding: "utf-8"}); + t.snapshot(stderr); + const results = JSON.parse(await readFile(new URL("ui5lint-results-fix.json", E2E_DIR_URL), {encoding: "utf-8"})); + t.snapshot(results); + + const projectFiles = (await readdir(APP_DIR_URL, {withFileTypes: true, recursive: true})) + .filter((dirEntries) => { + return dirEntries.isFile() && dirEntries.name !== ".DS_Store"; + }); + + for (const file of projectFiles) { + const content = await readFile(path.join(file.path, file.name), {encoding: "utf-8"}); + t.snapshot(`${file.name}:\n${content}`); + } +}); diff --git a/test/e2e/compare-snapshots.ts b/test/e2e/compare-ui5lint-snapshots.ts similarity index 80% rename from test/e2e/compare-snapshots.ts rename to test/e2e/compare-ui5lint-snapshots.ts index 185023850..0699d35ea 100644 --- a/test/e2e/compare-snapshots.ts +++ b/test/e2e/compare-ui5lint-snapshots.ts @@ -3,7 +3,7 @@ import {readFile} from "node:fs/promises"; const E2E_DIR_URL = new URL("../tmp/e2e/", import.meta.url); -test.serial("Compare com.ui5.troublesome.app result snapshots", async (t) => { +test.serial("Compare 'ui5lint' com.ui5.troublesome.app result snapshots", async (t) => { const stderr = await readFile(new URL("stderr.log", E2E_DIR_URL), {encoding: "utf-8"}); t.snapshot(stderr); const results = JSON.parse(await readFile(new URL("ui5lint-results.json", E2E_DIR_URL), {encoding: "utf-8"})); diff --git a/test/e2e/snapshots/autofix-e2e.ts.md b/test/e2e/snapshots/autofix-e2e.ts.md new file mode 100644 index 000000000..865008035 --- /dev/null +++ b/test/e2e/snapshots/autofix-e2e.ts.md @@ -0,0 +1,1211 @@ +# Snapshot report for `test/e2e/autofix-e2e.ts` + +The actual snapshot is saved in `autofix-e2e.ts.snap`. + +Generated by [AVA](https://avajs.dev). + +## Compare com.ui5.troublesome.app result snapshots + +> Snapshot 1 + + `ui5.yaml:␊ + specVersion: '3.0'␊ + metadata:␊ + name: com.ui5.troublesome.app␊ + type: application␊ + framework:␊ + name: OpenUI5␊ + version: "1.121.0"␊ + libraries:␊ + - name: sap.m␊ + - name: sap.ui.core␊ + - name: sap.landvisz␊ + ` + +> Snapshot 2 + + `ui5lint-custom-broken.config.cjs:␊ + // CodeQL code scan complains about this file being broken.␊ + // This is a case we test for the config manager. So, wrapping it in a JSON.parse to avoid the error there,␊ + // but keep the test case valid.␊ + module.exports = JSON.parse(\`{␊ + "ignores": [␊ + "test/**/*",␊ + "!test/sap/m/visual/Wizard.spe\`);␊ + ` + +> Snapshot 3 + + `ui5lint-custom.config.cjs:␊ + module.exports = {␊ + ignores: [␊ + "webapp/test/**/*",␊ + "!webapp/test/integration/opaTests.qunit.js",␊ + "ui5.yaml"␊ + ],␊ + };␊ + ` + +> Snapshot 4 + + `ui5lint.config.matched-patterns.mjs:␊ + export default {␊ + files: [␊ + "webapp/controller/*",␊ + "ui5.yaml",␊ + ],␊ + };␊ + ` + +> Snapshot 5 + + `ui5lint.config.mjs:␊ + export default {␊ + files: [␊ + "webapp/**/*"␊ + ],␊ + ignores: [␊ + "test/**/*", ␊ + "!test/sap/m/visual/Wizard.spec.js",␊ + ],␊ + };␊ + ` + +> Snapshot 6 + + `ui5lint.config.unmatched-patterns.mjs:␊ + export default {␊ + files: [␊ + "webapp/**/*",␊ + "unmatched-pattern1",␊ + "unmatched-pattern2",␊ + "unmatched-pattern3",␊ + ],␊ + ignores: [␊ + "test/**/*", ␊ + "!test/sap/m/visual/Wizard.spec.js",␊ + ],␊ + };␊ + ` + +> Snapshot 7 + + `manifest.json:␊ + {␊ + "_version": "1.12.0",␊ + ␊ + "sap.app": {␊ + "id": "com.ui5.troublesome.app",␊ + "type": "application",␊ + "i18n": "i18n/i18n.properties",␊ + "title": "{{appTitle}}",␊ + "description": "{{appDescription}}",␊ + "applicationVersion": {␊ + "version": "1.0.0"␊ + },␊ + "dataSources": {␊ + "v4": {␊ + "uri": "/api/odata-4/",␊ + "type": "OData",␊ + "settings": {␊ + "odataVersion": "4.0"␊ + }␊ + }␊ + }␊ + },␊ + ␊ + "sap.ui": {␊ + "technology": "UI5",␊ + "icons": {},␊ + "deviceTypes": {␊ + "desktop": true,␊ + "tablet": true,␊ + "phone": true␊ + }␊ + },␊ + ␊ + "sap.ui5": {␊ + "rootView": {␊ + "viewName": "com.ui5.troublesome.app.view.App",␊ + "type": "XML",␊ + "async": true,␊ + "id": "app"␊ + },␊ + ␊ + "dependencies": {␊ + "minUI5Version": "1.119.0",␊ + "libs": {␊ + "sap.ui.core": {},␊ + "sap.m": {},␊ + "sap.ui.commons": {}␊ + }␊ + },␊ + ␊ + "handleValidation": true,␊ + ␊ + "contentDensities": {␊ + "compact": true,␊ + "cozy": true␊ + },␊ + ␊ + "resources": {␊ + "js": [{ "uri": "path/to/thirdparty.js" }]␊ + },␊ + ␊ + "models": {␊ + "i18n": {␊ + "type": "sap.ui.model.resource.ResourceModel",␊ + "settings": {␊ + "bundleName": "com.ui5.troublesome.app.i18n.i18n"␊ + }␊ + },␊ + "odata-v4": {␊ + "type": "sap.ui.model.odata.v4.ODataModel",␊ + "settings": {␊ + "synchronizationMode": "None"␊ + }␊ + },␊ + "odata-v4-via-dataSource": {␊ + "dataSource": "v4",␊ + "settings": {␊ + "synchronizationMode": "None"␊ + }␊ + },␊ + "odata": {␊ + "type": "sap.ui.model.odata.ODataModel",␊ + "settings": {␊ + "serviceUrl": "/api/odata"␊ + }␊ + }␊ + },␊ + ␊ + "routing": {␊ + "config": {␊ + "routerClass": "sap.m.routing.Router",␊ + "viewType": "XML",␊ + "viewPath": "com.ui5.troublesome.app.view",␊ + "controlId": "app",␊ + "controlAggregation": "pages",␊ + "async": true␊ + },␊ + "routes": [␊ + {␊ + "pattern": "",␊ + "name": "main",␊ + "target": "main"␊ + }␊ + ],␊ + "targets": {␊ + "main": {␊ + "viewId": "main",␊ + "viewName": "Main"␊ + }␊ + }␊ + }␊ + }␊ + }␊ + ` + +> Snapshot 8 + + `Component.js:␊ + sap.ui.define(["sap/ui/core/UIComponent", "sap/ui/Device", "./model/models"], function (UIComponent, Device, models) {␊ + "use strict";␊ + ␊ + return UIComponent.extend("com.ui5.troublesome.app.Component", {␊ + metadata: {␊ + manifest: "json"␊ + },␊ + init: function () {␊ + // call the base component's init function␊ + UIComponent.prototype.init.call(this); // create the views based on the url/hash␊ + ␊ + // create the device model␊ + this.setModel(models.createDeviceModel(), "device");␊ + ␊ + // create the views based on the url/hash␊ + this.getRouter().initialize();␊ + },␊ + /**␊ + * This method can be called to determine whether the sapUiSizeCompact or sapUiSizeCozy␊ + * design mode class should be set, which influences the size appearance of some controls.␊ + * @public␊ + * @returns {string} css class, either 'sapUiSizeCompact' or 'sapUiSizeCozy' - or an empty string if no css class should be set␊ + */␊ + getContentDensityClass: function () {␊ + if (this.contentDensityClass === undefined) {␊ + // check whether FLP has already set the content density class; do nothing in this case␊ + if (document.body.classList.contains("sapUiSizeCozy") || document.body.classList.contains("sapUiSizeCompact")) {␊ + this.contentDensityClass = "";␊ + } else if (!Device.support.touch) {␊ + // apply "compact" mode if touch is not supported␊ + this.contentDensityClass = "sapUiSizeCompact";␊ + } else {␊ + // "cozy" in case of touch support; default for most sap.m controls, but needed for desktop-first controls like sap.ui.table.Table␊ + this.contentDensityClass = "sapUiSizeCozy";␊ + }␊ + }␊ + return this.contentDensityClass;␊ + }␊ + });␊ + });␊ + ` + +> Snapshot 9 + + `emptyFile.js:␊ + ` + +> Snapshot 10 + + `index-cdn.html:␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + UI5 Application: com.ui5.troublesome.app␊ + ␊ + ␊ + ␊ + ␊ + ␊ +
␊ + ␊ + ␊ + ` + +> Snapshot 11 + + `index.html:␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + UI5 Application: com.ui5.troublesome.app␊ + ␊ + ␊ + ␊ + ␊ + ␊ +
␊ + ␊ + ␊ + ` + +> Snapshot 12 + + `manifest.json:␊ + {␊ + "_version": "1.12.0",␊ + ␊ + "sap.app": {␊ + "id": "com.ui5.troublesome.app",␊ + "type": "application",␊ + "i18n": "i18n/i18n.properties",␊ + "title": "{{appTitle}}",␊ + "description": "{{appDescription}}",␊ + "applicationVersion": {␊ + "version": "1.0.0"␊ + },␊ + "dataSources": {␊ + "v4": {␊ + "uri": "/api/odata-4/",␊ + "type": "OData",␊ + "settings": {␊ + "odataVersion": "4.0"␊ + }␊ + }␊ + }␊ + },␊ + ␊ + "sap.ui": {␊ + "technology": "UI5",␊ + "icons": {},␊ + "deviceTypes": {␊ + "desktop": true,␊ + "tablet": true,␊ + "phone": true␊ + }␊ + },␊ + ␊ + "sap.ui5": {␊ + "rootView": {␊ + "viewName": "com.ui5.troublesome.app.view.App",␊ + "type": "XML",␊ + "async": true,␊ + "id": "app"␊ + },␊ + ␊ + "dependencies": {␊ + "minUI5Version": "1.119.0",␊ + "libs": {␊ + "sap.ui.core": {},␊ + "sap.m": {},␊ + "sap.ui.commons": {}␊ + }␊ + },␊ + ␊ + "handleValidation": true,␊ + ␊ + "contentDensities": {␊ + "compact": true,␊ + "cozy": true␊ + },␊ + ␊ + "resources": {␊ + "js": [{ "uri": "path/to/thirdparty.js" }]␊ + },␊ + ␊ + "models": {␊ + "i18n": {␊ + "type": "sap.ui.model.resource.ResourceModel",␊ + "settings": {␊ + "bundleName": "com.ui5.troublesome.app.i18n.i18n"␊ + }␊ + },␊ + "odata-v4": {␊ + "type": "sap.ui.model.odata.v4.ODataModel",␊ + "settings": {␊ + "synchronizationMode": "None"␊ + }␊ + },␊ + "odata-v4-via-dataSource": {␊ + "dataSource": "v4",␊ + "settings": {␊ + "synchronizationMode": "None"␊ + }␊ + },␊ + "odata": {␊ + "type": "sap.ui.model.odata.ODataModel",␊ + "settings": {␊ + "serviceUrl": "/api/odata"␊ + }␊ + }␊ + },␊ + ␊ + "routing": {␊ + "config": {␊ + "routerClass": "sap.m.routing.Router",␊ + "viewType": "XML",␊ + "viewPath": "com.ui5.troublesome.app.view",␊ + "controlId": "app",␊ + "controlAggregation": "pages",␊ + "async": true␊ + },␊ + "routes": [␊ + {␊ + "pattern": "",␊ + "name": "main",␊ + "target": "main"␊ + }␊ + ],␊ + "targets": {␊ + "main": {␊ + "viewId": "main",␊ + "viewName": "Main"␊ + }␊ + }␊ + }␊ + }␊ + }␊ + ` + +> Snapshot 13 + + `App.view.xml:␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ` + +> Snapshot 14 + + `DesktopMain.view.xml:␊ + ␊ + ␊ + ␊ +