diff --git a/CHANGELOG.md b/CHANGELOG.md index 694918573..ea1ba5444 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Support for MSBuild item and item metadata expressions in project files. - `ExhaustiveEnumCase` analysis rule, which flags `case` statements that do not handle all values in an enumeration. - **API:** `EnumeratorOccurrence` type. - **API:** `ForInStatementNode::getEnumeratorOccurrence` method. @@ -33,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Detect tab-indented multiline strings in `TabulationCharacter`. - Improve support for evaluating name references in compiler directive expressions. - Improve overload resolution in cases involving generic type parameter constraints. +- Improve handling for MSBuild properties, items, and conditional evaluation. ### Deprecated diff --git a/delphi-frontend/src/main/java/au/com/integradev/delphi/enviroment/EnvironmentProjVariableProvider.java b/delphi-frontend/src/main/java/au/com/integradev/delphi/enviroment/EnvironmentProjVariableProvider.java new file mode 100644 index 000000000..63ce940f5 --- /dev/null +++ b/delphi-frontend/src/main/java/au/com/integradev/delphi/enviroment/EnvironmentProjVariableProvider.java @@ -0,0 +1,52 @@ +/* + * Sonar Delphi Plugin + * Copyright (C) 2025 Integrated Application Development + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02 + */ +package au.com.integradev.delphi.enviroment; + +import au.com.integradev.delphi.msbuild.MSBuildParser; +import au.com.integradev.delphi.msbuild.MSBuildState; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.Map; + +public class EnvironmentProjVariableProvider implements EnvironmentVariableProvider { + private final MSBuildState state; + + public EnvironmentProjVariableProvider( + Path environmentProj, EnvironmentVariableProvider baseProvider) { + if (environmentProj != null && Files.exists(environmentProj)) { + var parser = new MSBuildParser(environmentProj, baseProvider); + state = parser.parse(); + } else { + state = + new MSBuildState( + environmentProj, environmentProj, baseProvider.getenv(), Collections.emptyMap()); + } + } + + @Override + public Map getenv() { + return state.getProperties(); + } + + @Override + public String getenv(String name) { + return state.getProperty(name); + } +} diff --git a/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/DelphiMSBuildParser.java b/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/DelphiMSBuildParser.java deleted file mode 100644 index 725cc507f..000000000 --- a/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/DelphiMSBuildParser.java +++ /dev/null @@ -1,225 +0,0 @@ -/* - * Sonar Delphi Plugin - * Copyright (C) 2019 Integrated Application Development - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with this program; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02 - */ -package au.com.integradev.delphi.msbuild; - -import au.com.integradev.delphi.enviroment.EnvironmentVariableProvider; -import au.com.integradev.delphi.msbuild.condition.ConditionEvaluator; -import au.com.integradev.delphi.utils.DelphiUtils; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.InvalidPathException; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import javax.xml.XMLConstants; -import org.apache.commons.io.FilenameUtils; -import org.jdom2.Document; -import org.jdom2.Element; -import org.jdom2.JDOMException; -import org.jdom2.input.SAXBuilder; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class DelphiMSBuildParser { - private static final Logger LOG = LoggerFactory.getLogger(DelphiMSBuildParser.class); - - private final Path path; - private final EnvironmentVariableProvider environmentVariableProvider; - private final Path environmentProj; - - private ProjectProperties properties; - private List sourceFiles; - private List projects; - - public DelphiMSBuildParser( - Path path, EnvironmentVariableProvider environmentVariableProvider, Path environmentProj) { - this.path = path; - this.environmentVariableProvider = environmentVariableProvider; - this.environmentProj = environmentProj; - } - - public Result parse() { - this.properties = createProperties(); - this.sourceFiles = new ArrayList<>(); - this.projects = new ArrayList<>(); - - final SAXBuilder builder = new SAXBuilder(); - builder.setProperty(XMLConstants.ACCESS_EXTERNAL_DTD, ""); - builder.setProperty(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); - - final Document dom; - try { - dom = builder.build(path.toFile()); - dom.getRootElement().getChildren().stream() - .filter(this::isConditionMet) - .forEach(this::parseTopLevelElement); - } catch (JDOMException | IOException e) { - LOG.error("Error while parsing {}: ", path.toAbsolutePath(), e); - } - - return new Result(properties, sourceFiles, projects); - } - - protected ProjectProperties createProperties() { - return ProjectProperties.create(environmentVariableProvider, environmentProj); - } - - private void parseTopLevelElement(Element element) { - parsePropertyGroup(element); - parseItemGroup(element); - parseImport(element); - } - - private void parsePropertyGroup(Element element) { - if (!element.getName().equals("PropertyGroup")) { - return; - } - - element.getChildren().stream().filter(this::isConditionMet).forEach(this::parseProperty); - } - - private void parseProperty(Element element) { - properties.set(element.getName(), properties.substitutor().replace(element.getValue())); - } - - private void parseItemGroup(Element element) { - if (!element.getName().equals("ItemGroup")) { - return; - } - - element.getChildren().stream() - .filter(this::isConditionMet) - .forEach(this::parseItemGroupElement); - } - - private void parseItemGroupElement(Element element) { - parseDCCReference(element); - parseProjects(element); - } - - private void parseDCCReference(Element element) { - String name = element.getName(); - if (!name.equals("DCCReference")) { - return; - } - - Path importPath = resolvePathFromElementAttribute(element, "Include"); - if (importPath == null) { - return; - } - - String extension = FilenameUtils.getExtension(importPath.toString()); - if (!"pas".equalsIgnoreCase(extension)) { - return; - } - - if (!(Files.exists(importPath) && Files.isRegularFile(importPath))) { - LOG.warn("File specified by {} does not exist: {}", name, importPath); - return; - } - - sourceFiles.add(importPath); - } - - private void parseProjects(Element element) { - String name = element.getName(); - if (!name.equals("Projects")) { - return; - } - - Path importPath = resolvePathFromElementAttribute(element, "Include"); - if (importPath == null) { - return; - } - - if (!(Files.exists(importPath) && Files.isRegularFile(importPath))) { - LOG.warn("File specified by {} does not exist: {}", name, importPath); - return; - } - - DelphiProjectParser parser = - new DelphiProjectParser(importPath, environmentVariableProvider, environmentProj); - projects.add(parser.parse()); - } - - private void parseImport(Element element) { - if (!element.getName().equals("Import")) { - return; - } - - Path importPath = resolvePathFromElementAttribute(element, "Project"); - if (importPath == null) { - return; - } - - DelphiOptionSetParser parser = - new DelphiOptionSetParser( - importPath, environmentVariableProvider, environmentProj, properties); - Result result = parser.parse(); - this.properties = result.getProperties(); - this.sourceFiles.addAll(result.getSourceFiles()); - } - - private boolean isConditionMet(Element element) { - ConditionEvaluator evaluator = new ConditionEvaluator(properties, evaluationDirectory()); - return evaluator.evaluate(element.getAttributeValue("Condition")); - } - - private Path resolvePathFromElementAttribute(Element element, String attribute) { - String include = element.getAttributeValue(attribute); - if (include != null) { - include = DelphiUtils.normalizeFileName(properties.substitutor().replace(include)); - try { - return DelphiUtils.resolvePathFromBaseDir(evaluationDirectory(), Path.of(include)); - } catch (InvalidPathException e) { - LOG.warn("Path specified by {} is invalid: {}", element.getName(), include); - LOG.debug("Exception:", e); - } - } - return null; - } - - private Path evaluationDirectory() { - return path.getParent(); - } - - public static class Result { - private final ProjectProperties properties; - private final List sourceFiles; - private final List projects; - - Result(ProjectProperties properties, List sourceFiles, List projects) { - this.properties = properties; - this.sourceFiles = sourceFiles; - this.projects = projects; - } - - public ProjectProperties getProperties() { - return properties; - } - - public List getSourceFiles() { - return sourceFiles; - } - - public List getProjects() { - return projects; - } - } -} diff --git a/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/DelphiMSBuildUtils.java b/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/DelphiMSBuildUtils.java new file mode 100644 index 000000000..ddaa40fe8 --- /dev/null +++ b/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/DelphiMSBuildUtils.java @@ -0,0 +1,64 @@ +/* + * Sonar Delphi Plugin + * Copyright (C) 2025 Integrated Application Development + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02 + */ +package au.com.integradev.delphi.msbuild; + +import au.com.integradev.delphi.enviroment.EnvironmentVariableProvider; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +final class DelphiMSBuildUtils { + private static final Logger LOG = LoggerFactory.getLogger(DelphiMSBuildUtils.class); + + private DelphiMSBuildUtils() { + // Utility class + } + + public static List getSourceFiles(MSBuildState state) { + return state.getItems("DCCReference").stream() + .filter(item -> item.getMetadata("Extension").equalsIgnoreCase(".pas")) + .map(MSBuildItem::getPath) + .filter(path -> regularFileExists(path, "DCCReference")) + .collect(Collectors.toList()); + } + + public static List getProjects( + MSBuildState state, EnvironmentVariableProvider environmentVariableProvider) { + return state.getItems("Projects").stream() + .map(MSBuildItem::getPath) + .filter(path -> regularFileExists(path, "Projects")) + .map( + path -> + new DelphiProjectFactory() + .createProject(new MSBuildParser(path, environmentVariableProvider).parse())) + .collect(Collectors.toList()); + } + + private static boolean regularFileExists(Path path, String specifiedBy) { + if (Files.exists(path) && Files.isRegularFile(path)) { + return true; + } else { + LOG.warn("File specified by {} does not exist: {}", specifiedBy, path); + return false; + } + } +} diff --git a/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/DelphiOptionSetParser.java b/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/DelphiOptionSetParser.java deleted file mode 100644 index 9dde3527e..000000000 --- a/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/DelphiOptionSetParser.java +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Sonar Delphi Plugin - * Copyright (C) 2019 Integrated Application Development - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with this program; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02 - */ -package au.com.integradev.delphi.msbuild; - -import au.com.integradev.delphi.enviroment.EnvironmentVariableProvider; -import java.nio.file.Path; - -class DelphiOptionSetParser extends DelphiMSBuildParser { - private final ProjectProperties properties; - - public DelphiOptionSetParser( - Path path, - EnvironmentVariableProvider environmentVariableProvider, - Path environmentProj, - ProjectProperties properties) { - super(path, environmentVariableProvider, environmentProj); - this.properties = properties; - } - - @Override - protected ProjectProperties createProperties() { - return properties.copy(); - } -} diff --git a/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/DelphiProjectParser.java b/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/DelphiProjectFactory.java similarity index 67% rename from delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/DelphiProjectParser.java rename to delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/DelphiProjectFactory.java index c24ed3ecc..1ff69bb2f 100644 --- a/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/DelphiProjectParser.java +++ b/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/DelphiProjectFactory.java @@ -22,7 +22,6 @@ */ package au.com.integradev.delphi.msbuild; -import au.com.integradev.delphi.enviroment.EnvironmentVariableProvider; import au.com.integradev.delphi.utils.DelphiUtils; import com.google.common.base.Splitter; import com.google.common.collect.ImmutableSortedMap; @@ -41,84 +40,75 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -final class DelphiProjectParser { - private static final Logger LOG = LoggerFactory.getLogger(DelphiProjectParser.class); +final class DelphiProjectFactory { + private static final Logger LOG = LoggerFactory.getLogger(DelphiProjectFactory.class); - private final Path dproj; - private final EnvironmentVariableProvider environmentVariableProvider; - private final Path environmentProj; + public DelphiProject createProject(MSBuildState state) { + var sourceFiles = DelphiMSBuildUtils.getSourceFiles(state); - public DelphiProjectParser( - Path dproj, EnvironmentVariableProvider environmentVariableProvider, Path environmentProj) { - this.dproj = dproj; - this.environmentVariableProvider = environmentVariableProvider; - this.environmentProj = environmentProj; - } - - public DelphiProject parse() { - var parser = new DelphiMSBuildParser(dproj, environmentVariableProvider, environmentProj); - DelphiMSBuildParser.Result result = parser.parse(); - - Path dprojDirectory = dproj.getParent(); + Path dproj = state.getThisFilePath(); + Path projectDirectory = dproj.getParent(); DelphiProjectImpl project = new DelphiProjectImpl(); - project.setDefinitions(createDefinitions(result.getProperties())); - project.setUnitScopeNames(createUnitScopeNames(result.getProperties())); - project.setSearchDirectories(createSearchDirectories(dprojDirectory, result.getProperties())); - project.setDebugSourceDirectories(createDebugSourceDirectories(result.getProperties())); - project.setLibraryPath(createLibraryPathDirectories(result.getProperties())); - project.setBrowsingPath(createBrowsingPathDirectories(result.getProperties())); - project.setUnitAliases(createUnitAliases(result.getProperties())); - project.setSourceFiles(result.getSourceFiles()); + project.setDefinitions(createDefinitions(state)); + project.setUnitScopeNames(createUnitScopeNames(state)); + project.setSearchDirectories( + createSearchDirectories(projectDirectory, state, projectDirectory)); + project.setDebugSourceDirectories(createDebugSourceDirectories(state, projectDirectory)); + project.setLibraryPath(createLibraryPathDirectories(state, projectDirectory)); + project.setBrowsingPath(createBrowsingPathDirectories(state, projectDirectory)); + project.setUnitAliases(createUnitAliases(state)); + project.setSourceFiles(sourceFiles); return project; } - private static Set createDefinitions(ProjectProperties properties) { - return Set.copyOf(propertyList(properties.get("DCC_Define"))); + private static Set createDefinitions(MSBuildState state) { + return Set.copyOf(propertyList(state.getProperty("DCC_Define"))); } - private static Set createUnitScopeNames(ProjectProperties properties) { - return Set.copyOf(propertyList(properties.get("DCC_Namespace"))); + private static Set createUnitScopeNames(MSBuildState state) { + return Set.copyOf(propertyList(state.getProperty("DCC_Namespace"))); } - private List createSearchDirectories(Path dprojDirectory, ProjectProperties properties) { + private List createSearchDirectories( + Path dprojDirectory, MSBuildState state, Path baseDir) { List result = new ArrayList<>(); result.add(dprojDirectory); - result.addAll(createPathList(properties, "DCC_UnitSearchPath")); + result.addAll(createPathList(state, "DCC_UnitSearchPath", baseDir)); return Collections.unmodifiableList(result); } - private List createDebugSourceDirectories(ProjectProperties properties) { - return createPathList(properties, "Debugger_DebugSourcePath"); + private List createDebugSourceDirectories(MSBuildState state, Path baseDir) { + return createPathList(state, "Debugger_DebugSourcePath", baseDir); } - private List createLibraryPathDirectories(ProjectProperties properties) { + private List createLibraryPathDirectories(MSBuildState state, Path baseDir) { List result = new ArrayList<>(); - result.addAll(createPathList(properties, "DelphiLibraryPath", false)); - result.addAll(createPathList(properties, "DelphiTranslatedLibraryPath", false)); + result.addAll(createPathList(state, "DelphiLibraryPath", baseDir, false)); + result.addAll(createPathList(state, "DelphiTranslatedLibraryPath", baseDir, false)); return Collections.unmodifiableList(result); } - private List createBrowsingPathDirectories(ProjectProperties properties) { - return createPathList(properties, "DelphiBrowsingPath", false); + private List createBrowsingPathDirectories(MSBuildState state, Path baseDir) { + return createPathList(state, "DelphiBrowsingPath", baseDir, false); } - private List createPathList(ProjectProperties properties, String propertyName) { - return createPathList(properties, propertyName, true); + private List createPathList(MSBuildState state, String propertyName, Path baseDir) { + return createPathList(state, propertyName, baseDir, true); } private List createPathList( - ProjectProperties properties, String propertyName, boolean emitWarnings) { + MSBuildState state, String propertyName, Path baseDir, boolean emitWarnings) { List result = new ArrayList<>(); - propertyList(properties.get(propertyName)) + propertyList(state.getProperty(propertyName)) .forEach( pathString -> { - Path path = resolveDirectory(pathString); + Path path = resolveDirectory(pathString, baseDir); if (path != null) { result.add(path); } else if (emitWarnings) { @@ -129,10 +119,10 @@ private List createPathList( } @Nullable - private Path resolveDirectory(String pathString) { + private Path resolveDirectory(String pathString, Path baseDir) { try { pathString = DelphiUtils.normalizeFileName(pathString); - Path path = DelphiUtils.resolvePathFromBaseDir(evaluationDirectory(), Path.of(pathString)); + Path path = DelphiUtils.resolvePathFromBaseDir(baseDir, Path.of(pathString)); if (Files.isDirectory(path)) { return path; } @@ -142,9 +132,9 @@ private Path resolveDirectory(String pathString) { return null; } - private static Map createUnitAliases(ProjectProperties properties) { + private static Map createUnitAliases(MSBuildState state) { Map result = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); - propertyList(properties.get("DCC_UnitAlias")) + propertyList(state.getProperty("DCC_UnitAlias")) .forEach( item -> { if (StringUtils.countMatches(item, '=') != 1) { @@ -166,10 +156,6 @@ private static List propertyList(String value) { return Splitter.on(';').omitEmptyStrings().splitToList(value); } - private Path evaluationDirectory() { - return dproj.getParent(); - } - private static class DelphiProjectImpl implements DelphiProject { private Set definitions = Collections.emptySet(); private Set unitScopeNames = Collections.emptySet(); diff --git a/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/DelphiProjectGroupParser.java b/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/DelphiProjectGroupParser.java deleted file mode 100644 index 773692dd7..000000000 --- a/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/DelphiProjectGroupParser.java +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Sonar Delphi Plugin - * Copyright (C) 2019 Integrated Application Development - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with this program; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02 - */ -package au.com.integradev.delphi.msbuild; - -import au.com.integradev.delphi.enviroment.EnvironmentVariableProvider; -import java.nio.file.Path; -import java.util.List; - -final class DelphiProjectGroupParser { - private final Path projectGroup; - private final EnvironmentVariableProvider environmentVariableProvider; - private final Path environmentProj; - - DelphiProjectGroupParser( - Path projectGroup, - EnvironmentVariableProvider environmentVariableProvider, - Path environmentProj) { - this.projectGroup = projectGroup; - this.environmentVariableProvider = environmentVariableProvider; - this.environmentProj = environmentProj; - } - - public List parse() { - return new DelphiMSBuildParser(projectGroup, environmentVariableProvider, environmentProj) - .parse() - .getProjects(); - } -} diff --git a/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/DelphiProjectHelper.java b/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/DelphiProjectHelper.java index b5d4da91f..98c11b31d 100644 --- a/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/DelphiProjectHelper.java +++ b/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/DelphiProjectHelper.java @@ -30,6 +30,7 @@ import au.com.integradev.delphi.compiler.PredefinedConditionals; import au.com.integradev.delphi.compiler.Toolchain; import au.com.integradev.delphi.core.Delphi; +import au.com.integradev.delphi.enviroment.EnvironmentProjVariableProvider; import au.com.integradev.delphi.enviroment.EnvironmentVariableProvider; import au.com.integradev.delphi.utils.DelphiUtils; import com.google.common.annotations.VisibleForTesting; @@ -45,6 +46,7 @@ import java.util.Map; import java.util.Set; import java.util.TreeMap; +import java.util.function.Supplier; import java.util.stream.Collectors; import org.apache.commons.lang3.EnumUtils; import org.apache.commons.lang3.StringUtils; @@ -64,6 +66,7 @@ public class DelphiProjectHelper { private final Configuration settings; private final FileSystem fs; private final EnvironmentVariableProvider environmentVariableProvider; + private final Supplier effectiveEnvironmentVariableProvider; private final List projects; private final Toolchain toolchain; private final CompilerVersion compilerVersion; @@ -101,6 +104,9 @@ public DelphiProjectHelper( this.conditionalDefines = getPredefinedConditionalDefines(); this.unitScopeNames = getSetFromSettings(DelphiProperties.UNIT_SCOPE_NAMES_KEY); this.unitAliases = getUnitAliasesFromSettings(); + this.effectiveEnvironmentVariableProvider = + () -> + new EnvironmentProjVariableProvider(environmentProjPath(), environmentVariableProvider); } private Set getSetFromSettings(String key) { @@ -243,17 +249,15 @@ Path environmentProjPath() { } private void indexProject(Path dprojFile) { - DelphiProjectParser parser = - new DelphiProjectParser(dprojFile, environmentVariableProvider, environmentProjPath()); - DelphiProject newProject = parser.parse(); + var state = new MSBuildParser(dprojFile, effectiveEnvironmentVariableProvider.get()).parse(); + DelphiProject newProject = new DelphiProjectFactory().createProject(state); projects.add(newProject); } private void indexProjectGroup(Path projectGroup) { - DelphiProjectGroupParser parser = - new DelphiProjectGroupParser( - projectGroup, environmentVariableProvider, environmentProjPath()); - projects.addAll(parser.parse()); + var state = new MSBuildParser(projectGroup, effectiveEnvironmentVariableProvider.get()).parse(); + projects.addAll( + DelphiMSBuildUtils.getProjects(state, effectiveEnvironmentVariableProvider.get())); } /** diff --git a/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/MSBuildItem.java b/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/MSBuildItem.java new file mode 100644 index 000000000..52c5653eb --- /dev/null +++ b/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/MSBuildItem.java @@ -0,0 +1,119 @@ +/* + * Sonar Delphi Plugin + * Copyright (C) 2025 Integrated Application Development + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02 + */ +package au.com.integradev.delphi.msbuild; + +import com.google.common.base.Suppliers; +import java.nio.file.Path; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import java.util.function.Supplier; +import org.apache.commons.io.FilenameUtils; + +public class MSBuildItem { + private final String identity; + private final String projectDirectory; + private final Supplier path; + private final Supplier fullPath; + private final Supplier rootDir; + private final Supplier fileName; + private final Supplier extension; + private final Supplier relativeDir; + private final Supplier directory; + private final Map customMetadata; + + public MSBuildItem(String identity, String projectDirectory, Map customMetadata) { + this.identity = identity; + this.projectDirectory = projectDirectory; + this.customMetadata = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + this.customMetadata.putAll(customMetadata); + + path = Suppliers.memoize(this::generatePath); + fullPath = Suppliers.memoize(this::resolve); + rootDir = Suppliers.memoize(this::rootDir); + fileName = Suppliers.memoize(this::fileName); + extension = Suppliers.memoize(this::extension); + relativeDir = Suppliers.memoize(this::relativeDir); + directory = Suppliers.memoize(this::directory); + } + + public String getIdentity() { + return identity; + } + + public Path getPath() { + return path.get(); + } + + private Path generatePath() { + return Path.of(fullPath.get()); + } + + public String getMetadata(String name) { + // Well-known metadata is automatically populated by MSBuild. We can't support all of them, + // but the ones that are simple transformations are relatively easy for us to calculate. + // https://learn.microsoft.com/en-us/visualstudio/msbuild/msbuild-well-known-item-metadata + switch (name.toLowerCase()) { + case "fullpath": + return fullPath.get(); + case "rootdir": + return rootDir.get(); + case "filename": + return fileName.get(); + case "extension": + return extension.get(); + case "relativedir": + return relativeDir.get(); + case "directory": + return directory.get(); + case "identity": + return identity; + default: + return customMetadata.getOrDefault(name, ""); + } + } + + private String resolve() { + return FilenameUtils.concat(projectDirectory, identity); + } + + private String rootDir() { + return FilenameUtils.getPrefix(fullPath.get()); + } + + private String fileName() { + return FilenameUtils.getBaseName(identity); + } + + private String extension() { + String ext = FilenameUtils.getExtension(identity); + if (Objects.equals(ext, "")) { + return ""; + } + return "." + ext; + } + + private String relativeDir() { + return FilenameUtils.getFullPath(identity); + } + + private String directory() { + return FilenameUtils.getPath(fullPath.get()); + } +} diff --git a/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/MSBuildParser.java b/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/MSBuildParser.java new file mode 100644 index 000000000..728055313 --- /dev/null +++ b/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/MSBuildParser.java @@ -0,0 +1,231 @@ +/* + * Sonar Delphi Plugin + * Copyright (C) 2019 Integrated Application Development + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02 + */ +package au.com.integradev.delphi.msbuild; + +import au.com.integradev.delphi.enviroment.EnvironmentVariableProvider; +import au.com.integradev.delphi.msbuild.condition.ConditionEvaluator; +import au.com.integradev.delphi.msbuild.expression.ExpressionEvaluator; +import au.com.integradev.delphi.utils.DelphiUtils; +import com.google.common.base.Splitter; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.Objects; +import java.util.Optional; +import java.util.TreeMap; +import java.util.stream.Stream; +import javax.xml.XMLConstants; +import org.apache.commons.lang3.StringUtils; +import org.jdom2.Document; +import org.jdom2.Element; +import org.jdom2.JDOMException; +import org.jdom2.input.SAXBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class MSBuildParser { + private static final Logger LOG = LoggerFactory.getLogger(MSBuildParser.class); + + private final Path thisFilePath; + private final Path projectPath; + private final EnvironmentVariableProvider environmentVariableProvider; + + private MSBuildState state; + private ExpressionEvaluator expressionEvaluator; + + public MSBuildParser(Path path, EnvironmentVariableProvider environmentVariableProvider) { + // The top file in the import tree is the project file + this(path, path, environmentVariableProvider); + } + + private MSBuildParser( + Path path, Path projectPath, EnvironmentVariableProvider environmentVariableProvider) { + this.thisFilePath = path; + this.projectPath = projectPath; + this.environmentVariableProvider = environmentVariableProvider; + } + + public MSBuildState parse() { + return parse(new MSBuildState(thisFilePath, projectPath, environmentVariableProvider)); + } + + public MSBuildState parse(MSBuildState initialState) { + this.state = initialState; + this.expressionEvaluator = new ExpressionEvaluator(this.state); + + final SAXBuilder builder = new SAXBuilder(); + builder.setProperty(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + builder.setProperty(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); + + final Document dom; + try { + dom = builder.build(thisFilePath.toFile()); + dom.getRootElement().getChildren().stream() + .filter(this::isConditionMet) + .forEach(this::parseTopLevelElement); + } catch (JDOMException | IOException e) { + LOG.error("Error while parsing {}: ", thisFilePath.toAbsolutePath(), e); + } + + return state; + } + + private void parseTopLevelElement(Element element) { + switch (element.getName()) { + case "PropertyGroup": + parsePropertyGroup(element); + break; + case "ItemGroup": + parseItemGroup(element); + break; + case "Import": + parseImport(element); + break; + default: + break; + } + } + + private void parsePropertyGroup(Element element) { + element.getChildren().stream().filter(this::isConditionMet).forEach(this::parseProperty); + } + + private void parseProperty(Element element) { + state.setProperty(element.getName(), expressionEvaluator.eval(element.getValue())); + } + + private void parseItemGroup(Element element) { + element.getChildren().stream().filter(this::isConditionMet).forEach(this::parseItem); + } + + private void parseItem(Element element) { + String identitiesStr = expressionEvaluator.eval(element.getAttributeValue("Include")); + + if (identitiesStr == null) { + // Items inside targets *can* omit an Include if they are updating metadata or removing + // specific items, but + // we can safely assume that all items outside of targets with no Include are no-ops. + return; + } + + if (identitiesStr.chars().anyMatch(c -> c == '*' || c == '?')) { + // MSBuild glob patterns are implemented differently from Java globs and are not common in + // Delphi dprojs. + // That being said, there's no particular reason we can't add support in the future, see: + // https://learn.microsoft.com/en-us/visualstudio/msbuild/msbuild-items?view=vs-2022#use-wildcards-to-specify-items + LOG.debug( + "{} glob patterns are not supported, interpreting literally: {}", + element.getName(), + identitiesStr); + } + + var identityGlobs = Splitter.on(';').trimResults().omitEmptyStrings().split(identitiesStr); + + for (String identity : identityGlobs) { + // We have to evaluate each separately because built-in metadata may be different for each + parseSingleItem(element, identity); + } + } + + private void parseSingleItem(Element element, String identity) { + var protoItem = + new MSBuildItem(identity, thisFilePath.getParent().toString(), Collections.emptyMap()); + var map = new TreeMap(String.CASE_INSENSITIVE_ORDER); + // In item metadata, successive definitions can refer to previous ones + var batchedExpressionEvaluator = + new ExpressionEvaluator( + state, + metadataName -> { + // We need the proto item for built-in metadata (FullPath, Extension, etc.) + var metadata = protoItem.getMetadata(metadataName); + if (metadata.isEmpty()) { + metadata = map.getOrDefault(metadataName, ""); + } + return metadata; + }); + + // Item metadata can be defined as attributes + element + .getAttributes() + .forEach(attr -> map.put(attr.getName(), batchedExpressionEvaluator.eval(attr.getValue()))); + + // Item metadata can be defined as child nodes + element.getChildren().stream() + .filter(this::isConditionMet) + .forEach( + metadata -> + map.put(metadata.getName(), batchedExpressionEvaluator.eval(metadata.getValue()))); + + state.addItem( + element.getName(), new MSBuildItem(identity, thisFilePath.getParent().toString(), map)); + } + + private void parseImport(Element element) { + if (!isConditionMet(element)) { + return; + } + + Optional importPath = resolvePathsFromElementAttribute(element, "Project").findFirst(); + + if (importPath.isEmpty()) { + return; + } else if (!Files.exists(importPath.get())) { + LOG.warn("Could not resolve imported file: {}", importPath.get()); + return; + } + + var importState = state.deriveState(importPath.get()); + new MSBuildParser(importPath.get(), projectPath, environmentVariableProvider) + .parse(importState); + state.absorbState(importState); + } + + private boolean isConditionMet(Element element) { + ConditionEvaluator evaluator = new ConditionEvaluator(state); + return evaluator.evaluate(element.getAttributeValue("Condition")); + } + + private Stream resolvePathsFromElementAttribute(Element element, String attribute) { + String include = expressionEvaluator.eval(element.getAttributeValue(attribute)); + if (include != null) { + return Arrays.stream(StringUtils.split(include, ';')) + .map(value -> resolvePath(value, element.getName())) + .filter(Objects::nonNull); + } + return Stream.empty(); + } + + private Path resolvePath(String path, String elementName) { + path = DelphiUtils.normalizeFileName(expressionEvaluator.eval(path)); + try { + return DelphiUtils.resolvePathFromBaseDir(evaluationDirectory(), Path.of(path)); + } catch (InvalidPathException e) { + LOG.warn("Path specified by {} is invalid: {}", elementName, path); + LOG.debug("Exception:", e); + } + return null; + } + + private Path evaluationDirectory() { + return thisFilePath.getParent(); + } +} diff --git a/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/MSBuildState.java b/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/MSBuildState.java new file mode 100644 index 000000000..2183b0c13 --- /dev/null +++ b/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/MSBuildState.java @@ -0,0 +1,115 @@ +/* + * Sonar Delphi Plugin + * Copyright (C) 2019 Integrated Application Development + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02 + */ +package au.com.integradev.delphi.msbuild; + +import au.com.integradev.delphi.enviroment.EnvironmentVariableProvider; +import au.com.integradev.delphi.msbuild.expression.MSBuildWellKnownPropertyHelper; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +public final class MSBuildState { + private final Map propertyMap; + private final Map> itemMap; + private final Path thisFilePath; + private final Path projectPath; + private final MSBuildWellKnownPropertyHelper wellKnownProperties; + + public MSBuildState( + Path thisFilePath, + Path projectPath, + Map propertyMap, + Map> itemMap) { + this.thisFilePath = thisFilePath; + this.projectPath = projectPath; + + this.propertyMap = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + this.propertyMap.putAll(propertyMap); + + this.itemMap = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + this.itemMap.putAll(itemMap); + + if (thisFilePath != null && projectPath != null) { + this.wellKnownProperties = + new MSBuildWellKnownPropertyHelper(thisFilePath.toString(), projectPath.toString()); + } else { + // If this state isn't attached to any concrete project file, we can't calculate props + this.wellKnownProperties = null; + } + } + + public MSBuildState( + Path thisFilePath, + Path projectPath, + EnvironmentVariableProvider environmentVariableProvider) { + this(thisFilePath, projectPath, environmentVariableProvider.getenv(), Collections.emptyMap()); + } + + public Path getThisFilePath() { + return thisFilePath; + } + + public MSBuildState deriveState(Path thisFilePath) { + return new MSBuildState(thisFilePath, projectPath, propertyMap, itemMap); + } + + public void absorbState(MSBuildState other) { + propertyMap.putAll(other.propertyMap); + itemMap.putAll(other.itemMap); + } + + public String getProperty(String name) { + var value = propertyMap.get(name); + if (value == null && wellKnownProperties != null) { + value = wellKnownProperties.getProperty(name); + } + return value == null ? "" : value; + } + + public void setProperty(String name, String value) { + propertyMap.put(name, value); + } + + public void addItem(String name, MSBuildItem value) { + if (itemMap.containsKey(name)) { + itemMap.get(name).add(value); + } else { + itemMap.put(name, new ArrayList<>(Collections.singletonList(value))); + } + } + + public void addItems(String name, List value) { + if (itemMap.containsKey(name)) { + itemMap.get(name).addAll(value); + } else { + itemMap.put(name, new ArrayList<>(value)); + } + } + + public List getItems(String name) { + return itemMap.getOrDefault(name, Collections.emptyList()); + } + + public Map getProperties() { + return Collections.unmodifiableMap(propertyMap); + } +} diff --git a/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/ProjectProperties.java b/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/ProjectProperties.java deleted file mode 100644 index 08ac28eb7..000000000 --- a/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/ProjectProperties.java +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Sonar Delphi Plugin - * Copyright (C) 2019 Integrated Application Development - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with this program; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02 - */ -package au.com.integradev.delphi.msbuild; - -import au.com.integradev.delphi.enviroment.EnvironmentVariableProvider; -import au.com.integradev.delphi.msbuild.DelphiMSBuildParser.Result; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Map; -import java.util.TreeMap; -import org.apache.commons.text.StringSubstitutor; -import org.apache.commons.text.lookup.StringLookup; - -public final class ProjectProperties { - private final Map propertyMap; - - private ProjectProperties(Map propertyMap) { - this.propertyMap = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); - this.propertyMap.putAll(propertyMap); - } - - public static ProjectProperties create( - EnvironmentVariableProvider environmentVariableProvider, Path environmentProj) { - if (environmentProj != null && Files.exists(environmentProj)) { - var parser = new DelphiMSBuildParser(environmentProj, environmentVariableProvider, null); - Result result = parser.parse(); - return result.getProperties(); - } else { - return new ProjectProperties(environmentVariableProvider.getenv()); - } - } - - public ProjectProperties copy() { - return new ProjectProperties(propertyMap); - } - - public String get(String name) { - return propertyMap.get(name); - } - - public void set(String name, String value) { - propertyMap.put(name, value); - } - - public StringSubstitutor substitutor() { - StringLookup lookup = new PropertyMapLookup(propertyMap); - return new StringSubstitutor(lookup) - .setVariablePrefix("$(") - .setVariableSuffix(")") - .setEscapeChar(Character.MIN_VALUE) - .setValueDelimiterMatcher(null) - .setDisableSubstitutionInValues(true); - } - - private static class PropertyMapLookup implements StringLookup { - private final Map valueMap; - - PropertyMapLookup(Map valueMap) { - this.valueMap = valueMap; - } - - @Override - public String lookup(String key) { - return valueMap.getOrDefault(key, ""); - } - } -} diff --git a/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/condition/BinaryExpression.java b/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/condition/BinaryExpression.java index d2816a0dc..8bec7ce72 100644 --- a/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/condition/BinaryExpression.java +++ b/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/condition/BinaryExpression.java @@ -19,6 +19,7 @@ package au.com.integradev.delphi.msbuild.condition; import au.com.integradev.delphi.msbuild.condition.Token.TokenType; +import au.com.integradev.delphi.msbuild.expression.ExpressionEvaluator; import java.util.Optional; import java.util.function.BiPredicate; import java.util.function.BinaryOperator; diff --git a/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/condition/ConditionEvaluator.java b/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/condition/ConditionEvaluator.java index 7453b6d7d..b16c33608 100644 --- a/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/condition/ConditionEvaluator.java +++ b/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/condition/ConditionEvaluator.java @@ -18,15 +18,15 @@ */ package au.com.integradev.delphi.msbuild.condition; -import au.com.integradev.delphi.msbuild.ProjectProperties; -import java.nio.file.Path; +import au.com.integradev.delphi.msbuild.MSBuildState; +import au.com.integradev.delphi.msbuild.expression.ExpressionEvaluator; import java.util.List; public final class ConditionEvaluator { private final ExpressionEvaluator expressionEvaluator; - public ConditionEvaluator(ProjectProperties properties, Path evaluationDirectory) { - expressionEvaluator = new ExpressionEvaluator(evaluationDirectory, properties.substitutor()); + public ConditionEvaluator(MSBuildState state) { + expressionEvaluator = new ExpressionEvaluator(state); } public boolean evaluate(String condition) { diff --git a/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/condition/ConditionLexer.java b/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/condition/ConditionLexer.java index 7e776432e..97e69fc43 100644 --- a/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/condition/ConditionLexer.java +++ b/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/condition/ConditionLexer.java @@ -192,7 +192,7 @@ private Token readQuotedString() { break; } - if (character == '$' && peekChar() == '(') { + if (character == '$' || character == '@' || character == '%') { expandable = true; } diff --git a/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/condition/Expression.java b/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/condition/Expression.java index e1ee6ab0e..ab8696403 100644 --- a/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/condition/Expression.java +++ b/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/condition/Expression.java @@ -18,6 +18,7 @@ */ package au.com.integradev.delphi.msbuild.condition; +import au.com.integradev.delphi.msbuild.expression.ExpressionEvaluator; import java.util.Optional; public interface Expression { diff --git a/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/condition/ExpressionEvaluator.java b/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/condition/ExpressionEvaluator.java deleted file mode 100644 index c455bf397..000000000 --- a/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/condition/ExpressionEvaluator.java +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Sonar Delphi Plugin - * Copyright (C) 2019 Integrated Application Development - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with this program; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02 - */ -package au.com.integradev.delphi.msbuild.condition; - -import java.nio.file.Path; -import org.apache.commons.text.StringSubstitutor; - -public final class ExpressionEvaluator { - private final Path evaluationDirectory; - private final StringSubstitutor substitutor; - - public ExpressionEvaluator(Path evaluationDirectory, StringSubstitutor substitutor) { - this.evaluationDirectory = evaluationDirectory; - this.substitutor = substitutor; - } - - public String expand(String value) { - return substitutor.replace(value); - } - - public Path getEvaluationDirectory() { - return evaluationDirectory; - } -} diff --git a/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/condition/FunctionCallExpression.java b/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/condition/FunctionCallExpression.java index 9fae8bc0c..5a5b270c9 100644 --- a/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/condition/FunctionCallExpression.java +++ b/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/condition/FunctionCallExpression.java @@ -18,6 +18,7 @@ */ package au.com.integradev.delphi.msbuild.condition; +import au.com.integradev.delphi.msbuild.expression.ExpressionEvaluator; import au.com.integradev.delphi.utils.DelphiUtils; import com.google.common.base.Splitter; import java.nio.file.Files; @@ -57,7 +58,7 @@ private boolean exists(ExpressionEvaluator evaluator) { return false; } - Path baseDir = evaluator.getEvaluationDirectory(); + Path baseDir = evaluator.getState().getThisFilePath().getParent(); try { return Stream.of(StringUtils.split(value, ";")) .map(DelphiUtils::normalizeFileName) diff --git a/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/condition/NotExpression.java b/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/condition/NotExpression.java index 17ab52ca5..e989440d5 100644 --- a/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/condition/NotExpression.java +++ b/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/condition/NotExpression.java @@ -18,6 +18,7 @@ */ package au.com.integradev.delphi.msbuild.condition; +import au.com.integradev.delphi.msbuild.expression.ExpressionEvaluator; import java.util.Optional; public class NotExpression implements Expression { diff --git a/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/condition/NumericExpression.java b/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/condition/NumericExpression.java index c189091f3..3c6cc455a 100644 --- a/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/condition/NumericExpression.java +++ b/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/condition/NumericExpression.java @@ -18,6 +18,7 @@ */ package au.com.integradev.delphi.msbuild.condition; +import au.com.integradev.delphi.msbuild.expression.ExpressionEvaluator; import au.com.integradev.delphi.msbuild.utils.NumericUtils; import au.com.integradev.delphi.msbuild.utils.VersionUtils; import java.util.Optional; diff --git a/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/condition/StringExpression.java b/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/condition/StringExpression.java index a9ee2d3e0..512c47441 100644 --- a/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/condition/StringExpression.java +++ b/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/condition/StringExpression.java @@ -18,6 +18,7 @@ */ package au.com.integradev.delphi.msbuild.condition; +import au.com.integradev.delphi.msbuild.expression.ExpressionEvaluator; import au.com.integradev.delphi.msbuild.utils.NumericUtils; import au.com.integradev.delphi.msbuild.utils.VersionUtils; import com.google.common.collect.ImmutableSortedSet; @@ -78,7 +79,7 @@ public Optional getExpandedValue(ExpressionEvaluator evaluator) { return getValue(); } if (expandedValue == null) { - expandedValue = evaluator.expand(value); + expandedValue = evaluator.eval(value); } return Optional.of(expandedValue); } diff --git a/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/expression/ExpressionEvaluator.java b/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/expression/ExpressionEvaluator.java new file mode 100644 index 000000000..db42bc7d4 --- /dev/null +++ b/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/expression/ExpressionEvaluator.java @@ -0,0 +1,304 @@ +/* + * Sonar Delphi Plugin + * Copyright (C) 2025 Integrated Application Development + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02 + */ +package au.com.integradev.delphi.msbuild.expression; + +import au.com.integradev.delphi.msbuild.MSBuildItem; +import au.com.integradev.delphi.msbuild.MSBuildState; +import java.util.Optional; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.function.UnaryOperator; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ExpressionEvaluator { + private static final Logger LOG = LoggerFactory.getLogger(ExpressionEvaluator.class); + private static final Pattern METADATA_EXPRESSION_PATTERN = + Pattern.compile("%\\(\\s*(\\w+)\\s*\\)"); + + private final MSBuildState state; + private String text; + private int pos; + private final UnaryOperator metadataProvider; + + private char nextChar() { + if (pos >= text.length()) { + return 0; + } + return text.charAt(pos++); + } + + private char peekChar() { + if (pos >= text.length()) { + return 0; + } + return text.charAt(pos); + } + + private boolean eof() { + return pos == text.length(); + } + + public ExpressionEvaluator(MSBuildState state) { + this(state, null); + } + + public ExpressionEvaluator(MSBuildState state, UnaryOperator metadataProvider) { + this.state = state; + this.metadataProvider = metadataProvider; + } + + public String eval(String text) { + if (text == null || text.isBlank()) { + return text; + } + + this.text = text; + this.pos = 0; + return parseTopLevel(false, true); + } + + public MSBuildState getState() { + return state; + } + + private Optional tryParse(Supplier> parseFunc) { + int savedPos = pos; + var result = parseFunc.get(); + if (result.isEmpty()) { + pos = savedPos; + } + return result; + } + + private String parseTopLevel(boolean stopAtQuote, boolean parseMetadata) { + var result = new StringBuilder(); + + while (!eof()) { + int prevPos = pos; + + // Handle MSBuild expressions starting at this char + switch (peekChar()) { + case '$': + tryParse(this::parseProperty).ifPresent(result::append); + break; + case '@': + tryParse(this::parseItem).ifPresent(result::append); + break; + case '%': + if (parseMetadata) { + tryParse(this::parseMetadata).ifPresent(result::append); + } + break; + case '\'': + if (stopAtQuote) { + return result.toString(); + } + break; + default: + } + + if (prevPos == pos) { + // Nothing more complex was parseable, simply treat it as normal text + result.append(nextChar()); + } + } + + return result.toString(); + } + + private Optional parseItem() { + if (nextChar() != '@') return Optional.empty(); + if (nextChar() != '(') return Optional.empty(); + + skipWhitespace(); + Optional ident = parseIdentifier(); + if (ident.isEmpty()) return Optional.empty(); + skipWhitespace(); + + Function transform = MSBuildItem::getIdentity; + String separator = ";"; + + if (peekChar() == '-') { + var maybeTransform = parseItemTransform(); + if (maybeTransform.isEmpty()) return Optional.empty(); + transform = maybeTransform.get(); + + skipWhitespace(); + + if (peekChar() == '-') { + unsupportedFeature("chained item transform"); + return Optional.empty(); + } + } + + if (peekChar() == ',') { + nextChar(); + skipWhitespace(); + var maybeSeparator = parseSimpleString(); + if (maybeSeparator.isEmpty()) return Optional.empty(); + separator = maybeSeparator.get(); + skipWhitespace(); + } + + if (nextChar() != ')') return Optional.empty(); + + return Optional.of(concatItems(ident.get(), transform, separator)); + } + + private String concatItems( + String name, Function transform, CharSequence separator) { + return state.getItems(name).stream().map(transform).collect(Collectors.joining(separator)); + } + + private Optional> parseItemTransform() { + if (nextChar() != '-') return Optional.empty(); + if (nextChar() != '>') return Optional.empty(); + + return parseItemTransformExpression(); + } + + private Optional> parseItemTransformExpression() { + if (isSimpleStringChar(peekChar())) { + unsupportedFeature("item transform function"); + return Optional.empty(); + } + + if (nextChar() != '\'') return Optional.empty(); + String expression = parseTopLevel(true, false); + if (nextChar() != '\'') return Optional.empty(); + + return Optional.of(item -> expandMetadataValues(item, expression)); + } + + private String expandMetadataValues(MSBuildItem item, String expression) { + return METADATA_EXPRESSION_PATTERN + .matcher(expression) + .replaceAll(result -> item.getMetadata(result.group(1))); + } + + private Optional parseProperty() { + if (nextChar() != '$') return Optional.empty(); + if (nextChar() != '(') return Optional.empty(); + if (peekChar() == '[') { + unsupportedFeature("static property function"); + return Optional.empty(); + } + + var discard = skipWhitespace(); + Optional ident = parseIdentifier(); + if (ident.isEmpty()) { + return Optional.empty(); + } + + if (peekChar() == '.') { + unsupportedFeature("string property function"); + return Optional.empty(); + } + + discard = skipWhitespace() || discard; + if (nextChar() != ')') return Optional.empty(); + + if (discard) { + // Whitespace is significant inside property expressions, but it's impossible to + // define properties with spaces, so expressions with spaces always evaluate to "". + return Optional.of(""); + } else { + return Optional.of(state.getProperty(ident.get())); + } + } + + private Optional parseMetadata() { + if (nextChar() != '%') return Optional.empty(); + if (nextChar() != '(') return Optional.empty(); + + var discard = skipWhitespace(); + Optional ident = parseIdentifier(); + if (ident.isEmpty()) return Optional.empty(); + + discard = skipWhitespace() || discard; + + if (nextChar() != ')') return Optional.empty(); + + if (discard) { + // Whitespace is significant inside metadata expressions, but it's impossible to + // define metadata with spaces, so expressions with spaces always evaluate to "". + return Optional.of(""); + } else if (metadataProvider != null) { + return Optional.of(metadataProvider.apply(ident.get())); + } else { + return Optional.empty(); + } + } + + private static boolean isSimpleStringStart(char character) { + return Character.isAlphabetic(character) || character == '_'; + } + + private static boolean isSimpleStringChar(char character) { + return isSimpleStringStart(character) || Character.isDigit(character); + } + + private Optional parseSimpleString() { + if (nextChar() != '\'') return Optional.empty(); + + StringBuilder builder = new StringBuilder(); + + char chr; + while ((chr = nextChar()) != '\'') { + builder.append(chr); + } + + return Optional.of(builder.toString()); + } + + private Optional parseIdentifier() { + if (!isSimpleStringStart(peekChar())) return Optional.empty(); + + StringBuilder builder = new StringBuilder(); + + char chr; + while (isSimpleStringChar(chr = peekChar())) { + builder.append(chr); + nextChar(); + } + + return Optional.of(builder.toString()); + } + + private boolean skipWhitespace() { + var skipped = false; + while (Character.isWhitespace(peekChar())) { + nextChar(); + skipped = true; + } + return skipped; + } + + private void unsupportedFeature(String feature) { + // We don't know the relative importance of this expression - it could be an expression that + // SonarDelphi + // never needs to read. Making this a warning despite having a well-defined behaviour for when + // an unsupported + // feature is encountered (to interpret it literally) would probably be overkill. + LOG.debug("Unsupported MSBuild feature '{}' ignored in expression: {}", feature, text); + } +} diff --git a/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/expression/MSBuildWellKnownPropertyHelper.java b/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/expression/MSBuildWellKnownPropertyHelper.java new file mode 100644 index 000000000..77d81d9d4 --- /dev/null +++ b/delphi-frontend/src/main/java/au/com/integradev/delphi/msbuild/expression/MSBuildWellKnownPropertyHelper.java @@ -0,0 +1,126 @@ +/* + * Sonar Delphi Plugin + * Copyright (C) 2025 Integrated Application Development + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02 + */ +package au.com.integradev.delphi.msbuild.expression; + +import com.google.common.base.Suppliers; +import java.util.Objects; +import java.util.function.Supplier; +import org.apache.commons.io.FilenameUtils; + +public class MSBuildWellKnownPropertyHelper { + private final WellKnownFile project; + private final WellKnownFile thisFile; + + public MSBuildWellKnownPropertyHelper(String thisFilePath, String projectPath) { + this.project = new WellKnownFile(projectPath); + this.thisFile = new WellKnownFile(thisFilePath); + } + + private static class WellKnownFile { + private final Supplier extension; + private final Supplier file; + private final Supplier fullPath; + private final Supplier name; + private final Supplier directory; + private final Supplier directoryNoRoot; + + public WellKnownFile(String path) { + this.extension = + Suppliers.memoize( + () -> { + var ext = FilenameUtils.getExtension(path); + if (Objects.equals(ext, "")) { + return ""; + } + return "." + ext; + }); + this.file = Suppliers.memoize(() -> FilenameUtils.getName(path)); + this.fullPath = Suppliers.memoize(() -> path); + this.name = Suppliers.memoize(() -> FilenameUtils.getBaseName(path)); + this.directory = Suppliers.memoize(() -> FilenameUtils.getFullPath(fullPath.get())); + this.directoryNoRoot = + Suppliers.memoize( + () -> { + var dir = directory.get(); + return dir.substring(FilenameUtils.getPrefixLength(dir)); + }); + } + + public String getExtension() { + return extension.get(); + } + + public String getFile() { + return file.get(); + } + + public String getFullPath() { + return fullPath.get(); + } + + public String getName() { + return name.get(); + } + + public String getDirectory() { + return directory.get(); + } + + public String getDirectoryNoRoot() { + return directoryNoRoot.get(); + } + } + + public String getProperty(String name) { + // Well-known metadata is automatically populated by MSBuild. We can't support all of them, + // but the ones that are simple transformations are relatively easy for us to calculate. + // https://learn.microsoft.com/en-us/visualstudio/msbuild/msbuild-reserved-and-well-known-properties + + switch (name.toLowerCase()) { + case "os": + return "Windows_NT"; + case "msbuildthisfilename": + return thisFile.getName(); + case "msbuildthisfilefullpath": + return thisFile.getFullPath(); + case "msbuildthisfileextension": + return thisFile.getExtension(); + case "msbuildthisfiledirectorynoroot": + return thisFile.getDirectoryNoRoot(); + case "msbuildthisfiledirectory": + return thisFile.getDirectory(); + case "msbuildthisfile": + return thisFile.getFile(); + case "msbuildprojectname": + return project.getName(); + case "msbuildprojectfullpath": + return project.getFullPath(); + case "msbuildprojectfile": + return project.getFile(); + case "msbuildprojectextension": + return project.getExtension(); + case "msbuildprojectdirectory": + return project.getDirectory(); + case "msbuildprojectdirectorynoroot": + return project.getDirectoryNoRoot(); + default: + return null; + } + } +} diff --git a/delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/DelphiProjectGroupParserTest.java b/delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/DelphiMSBuildUtilsTest.java similarity index 76% rename from delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/DelphiProjectGroupParserTest.java rename to delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/DelphiMSBuildUtilsTest.java index 2320ef185..569068a08 100644 --- a/delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/DelphiProjectGroupParserTest.java +++ b/delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/DelphiMSBuildUtilsTest.java @@ -27,11 +27,11 @@ import au.com.integradev.delphi.utils.DelphiUtils; import java.nio.file.Path; import java.util.Collections; -import java.util.List; +import org.assertj.core.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -class DelphiProjectGroupParserTest { +class DelphiMSBuildUtilsTest { private static final String PROJECT_GROUP = "/au/com/integradev/delphi/msbuild/ProjectGroup.groupproj"; @@ -39,13 +39,10 @@ class DelphiProjectGroupParserTest { "/au/com/integradev/delphi/msbuild/ProjectGroupWithInvalidProjects.groupproj"; private EnvironmentVariableProvider environmentVariableProvider; - private Path environmentProj; - private List parse(String resource) { + private MSBuildState parse(String resource) { Path groupproj = DelphiUtils.getResource(resource).toPath(); - DelphiProjectGroupParser parser = - new DelphiProjectGroupParser(groupproj, environmentVariableProvider, environmentProj); - return parser.parse(); + return new MSBuildParser(groupproj, environmentVariableProvider).parse(); } @BeforeEach @@ -53,16 +50,20 @@ void init() { environmentVariableProvider = mock(EnvironmentVariableProvider.class); when(environmentVariableProvider.getenv()).thenReturn(Collections.emptyMap()); when(environmentVariableProvider.getenv(anyString())).thenReturn(null); - environmentProj = null; } @Test - void testProjectGroup() { - assertThat(parse(PROJECT_GROUP)).hasSize(3); + void testGetProjectsWithValidProjects() { + Assertions.assertThat( + DelphiMSBuildUtils.getProjects(parse(PROJECT_GROUP), environmentVariableProvider)) + .hasSize(3); } @Test - void testProjectGroupWithInvalidProjects() { - assertThat(parse(PROJECT_GROUP_WITH_INVALID_PROJECTS)).hasSize(1); + void testGetProjectsWithInvalidProjects() { + assertThat( + DelphiMSBuildUtils.getProjects( + parse(PROJECT_GROUP_WITH_INVALID_PROJECTS), environmentVariableProvider)) + .hasSize(1); } } diff --git a/delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/DelphiProjectParserTest.java b/delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/DelphiProjectFactoryTest.java similarity index 79% rename from delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/DelphiProjectParserTest.java rename to delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/DelphiProjectFactoryTest.java index 5abf85d46..5081e21d5 100644 --- a/delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/DelphiProjectParserTest.java +++ b/delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/DelphiProjectFactoryTest.java @@ -35,13 +35,16 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -class DelphiProjectParserTest { +class DelphiProjectFactoryTest { private static final String SIMPLE_PROJECT = "/au/com/integradev/delphi/projects/SimpleProject/dproj/SimpleDelphiProject.dproj"; private static final String OPT_SET_PROJECT = "/au/com/integradev/delphi/msbuild/OptSet.dproj"; + private static final String COMPLEX_SYNTAX_OPT_SET_PROJECT = + "/au/com/integradev/delphi/msbuild/ComplexSyntax.optset"; + private static final String BAD_OPT_SET_PROJECT = "/au/com/integradev/delphi/msbuild/BadOptSet.dproj"; @@ -61,13 +64,11 @@ class DelphiProjectParserTest { "/au/com/integradev/delphi/msbuild/BrowsingPath.dproj"; private EnvironmentVariableProvider environmentVariableProvider; - private Path environmentProj; - private DelphiProject parse(String resource) { + private DelphiProject createProject(String resource) { Path dproj = DelphiUtils.getResource(resource).toPath(); - DelphiProjectParser parser = - new DelphiProjectParser(dproj, environmentVariableProvider, environmentProj); - return parser.parse(); + DelphiProjectFactory parser = new DelphiProjectFactory(); + return parser.createProject(new MSBuildParser(dproj, environmentVariableProvider).parse()); } @BeforeEach @@ -75,12 +76,11 @@ void init() { environmentVariableProvider = mock(EnvironmentVariableProvider.class); when(environmentVariableProvider.getenv()).thenReturn(Collections.emptyMap()); when(environmentVariableProvider.getenv(anyString())).thenReturn(null); - environmentProj = null; } @Test void testSimpleProjectFile() { - DelphiProject project = parse(SIMPLE_PROJECT); + DelphiProject project = createProject(SIMPLE_PROJECT); assertThat(project.getSourceFiles()).hasSize(8); @@ -124,29 +124,43 @@ void testSimpleProjectFile() { @Test void testOptSetProject() { - DelphiProject project = parse(OPT_SET_PROJECT); + DelphiProject project = createProject(OPT_SET_PROJECT); assertThat(project.getUnitAliases()) .containsExactlyInAnyOrderEntriesOf(Map.of("WinProcs", "Windows", "WinTypes", "Windows")); } @Test void testBadOptSetProjectShouldContainValidOptsetValues() { - DelphiProject project = parse(BAD_OPT_SET_PROJECT); + DelphiProject project = createProject(BAD_OPT_SET_PROJECT); assertThat(project.getUnitAliases()) .containsExactlyInAnyOrderEntriesOf(Map.of("WinProcs", "Windows", "WinTypes", "Windows")); } @Test void testBadUnitAliasProjectShouldContainValidAliases() { - DelphiProject project = parse(BAD_UNIT_ALIAS_PROJECT); + DelphiProject project = createProject(BAD_UNIT_ALIAS_PROJECT); assertThat(project.getUnitAliases()) .containsExactlyInAnyOrderEntriesOf(Map.of("ValidAlias", "ValidUnit")); } + @Test + void testComplexSyntax() { + DelphiProject project = createProject(COMPLEX_SYNTAX_OPT_SET_PROJECT); + + assertThat(project.getUnitAliases()) + .containsExactlyInAnyOrderEntriesOf( + Map.of("WinTypes", "Windows", "WinProcs", "Windows", "Foo", "Bar")); + + var baseResourcePath = DelphiUtils.getResource("/au/com/integradev/delphi/msbuild").toPath(); + assertThat(project.getSearchDirectories()) + .containsExactlyInAnyOrder( + baseResourcePath, baseResourcePath.resolve("subdir/22.0mysuffix")); + } + @Test void testBadSearchPathProjectShouldContainValidSearchPaths() { - DelphiProject project = parse(BAD_SEARCH_PATH_PROJECT); + DelphiProject project = createProject(BAD_SEARCH_PATH_PROJECT); assertThat(project.getSearchDirectories()) .containsOnly(DelphiUtils.getResource("/au/com/integradev/delphi/msbuild").toPath()); @@ -154,7 +168,7 @@ void testBadSearchPathProjectShouldContainValidSearchPaths() { @Test void testBadSourceFileProjectShouldContainValidSourceFiles() { - DelphiProject project = parse(BAD_SOURCE_FILE_PROJECT); + DelphiProject project = createProject(BAD_SOURCE_FILE_PROJECT); assertThat(project.getSourceFiles()) .containsOnly(DelphiUtils.getResource("/au/com/integradev/delphi/file/Empty.pas").toPath()); @@ -162,7 +176,7 @@ void testBadSourceFileProjectShouldContainValidSourceFiles() { @Test void testLibraryPathProject() { - DelphiProject project = parse(LIBRARY_PATH_PROJECT); + DelphiProject project = createProject(LIBRARY_PATH_PROJECT); assertThat(project.getLibraryPathDirectories()) .containsExactly( @@ -172,7 +186,7 @@ void testLibraryPathProject() { @Test void testBrowsingPathProject() { - DelphiProject project = parse(BROWSING_PATH_PROJECT); + DelphiProject project = createProject(BROWSING_PATH_PROJECT); assertThat(project.getBrowsingPathDirectories()) .containsExactly(DelphiUtils.getResource("/au/com/integradev/delphi").toPath()); diff --git a/delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/MSBuildItemTest.java b/delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/MSBuildItemTest.java new file mode 100644 index 000000000..7f690135a --- /dev/null +++ b/delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/MSBuildItemTest.java @@ -0,0 +1,119 @@ +/* + * Sonar Delphi Plugin + * Copyright (C) 2025 Integrated Application Development + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02 + */ +package au.com.integradev.delphi.msbuild; + +import static org.assertj.core.api.Assertions.*; + +import java.util.Collections; +import java.util.Map; +import java.util.stream.Stream; +import org.apache.commons.io.FilenameUtils; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.ArgumentsProvider; +import org.junit.jupiter.params.provider.ArgumentsSource; +import org.junit.jupiter.params.provider.ValueSource; + +class MSBuildItemTest { + private static final String PROJECT_DIR = "C:\\project"; + + static class SupportedWellKnownMetadataArgumentsProvider implements ArgumentsProvider { + @Override + public Stream provideArguments(ExtensionContext context) { + return Stream.of( + Arguments.of("FullPath", "C:\\foo\\bar\\baz.qux", "C:\\foo\\bar\\baz.qux"), + Arguments.of("FullPath", "foo", "C:\\project\\foo"), + Arguments.of("RootDir", "C:\\foo\\bar\\baz.qux", "C:\\"), + Arguments.of("RootDir", "foo", "C:\\"), + Arguments.of("Filename", "C:\\foo\\bar\\baz.qux", "baz"), + Arguments.of("Filename", "foo", "foo"), + Arguments.of("Extension", "C:\\foo\\bar\\baz.qux", ".qux"), + Arguments.of("Extension", "foo", ""), + Arguments.of("RelativeDir", "C:\\foo\\bar\\baz.qux", "C:\\foo\\bar\\"), + Arguments.of("RelativeDir", "flarp\\foo", "flarp\\"), + Arguments.of("RelativeDir", "foo", ""), + Arguments.of("Directory", "C:\\foo\\bar\\baz.qux", "foo\\bar\\"), + Arguments.of("Directory", "flarp\\foo", "project\\flarp\\"), + Arguments.of("Directory", "foo", "project\\"), + Arguments.of("Identity", "C:\\foo\\bar\\baz.qux", "C:\\foo\\bar\\baz.qux"), + Arguments.of("Identity", "flarp\\foo", "flarp\\foo"), + Arguments.of("Identity", "foo", "foo")); + } + } + + static class CustomMetadataArgumentsProvider implements ArgumentsProvider { + @Override + public Stream provideArguments(ExtensionContext context) { + return Stream.of( + Arguments.of("FooMetadata", "foometadata", "foo value"), + Arguments.of("FooMetadata", "FooMetadata", "foo value"), + Arguments.of("foo_metadata", "Foo_Metadata", "foo value")); + } + } + + @ParameterizedTest(name = "({1}).{0} = {2}") + @ArgumentsSource(SupportedWellKnownMetadataArgumentsProvider.class) + void testWellKnownMetadataEvaluation(String metadataName, String itemIdentity, String expected) { + Assertions.assertThat( + // Need to convert to Windows separators in case the test is running on Unix + FilenameUtils.separatorsToWindows( + new MSBuildItem(itemIdentity, PROJECT_DIR, Collections.emptyMap()) + .getMetadata(metadataName))) + .isEqualTo(expected); + } + + @ParameterizedTest + @ValueSource( + strings = { + "RecursiveDir", + "ModifiedTime", + "CreatedTime", + "AccessedTime", + "DefiningProjectFullPath", + "DefiningProjectDirectory", + "DefiningProjectName", + "DefiningProjectExtension" + }) + void testUnsupportedWellKnownMetadataEvaluatesToEmpty(String metadataName) { + assertThat( + new MSBuildItem("C:\\foo\\bar\\baz.qux", PROJECT_DIR, Collections.emptyMap()) + .getMetadata(metadataName)) + .isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = {"foometa", "barmeta", "", "MSBuildThisFileDirectory"}) + void testNonexistentCustomMetadataEvaluatesToEmpty(String metadataName) { + assertThat( + new MSBuildItem("C:\\foo\\bar\\baz.qux", PROJECT_DIR, Collections.emptyMap()) + .getMetadata(metadataName)) + .isEmpty(); + } + + @ParameterizedTest + @ArgumentsSource(CustomMetadataArgumentsProvider.class) + void testExistingCustomMetadataEvaluation(String inputName, String outputName, String expected) { + assertThat( + new MSBuildItem("C:\\foo\\bar\\baz.qux", PROJECT_DIR, Map.of(inputName, expected)) + .getMetadata(outputName)) + .isEqualTo(expected); + } +} diff --git a/delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/MSBuildParserTest.java b/delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/MSBuildParserTest.java new file mode 100644 index 000000000..e9790659f --- /dev/null +++ b/delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/MSBuildParserTest.java @@ -0,0 +1,257 @@ +/* + * Sonar Delphi Plugin + * Copyright (C) 2025 Integrated Application Development + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02 + */ +package au.com.integradev.delphi.msbuild; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import au.com.integradev.delphi.enviroment.EnvironmentVariableProvider; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class MSBuildParserTest { + private EnvironmentVariableProvider environmentVariableProvider; + + @TempDir private Path tempDir; + + @BeforeEach + void setUp() { + environmentVariableProvider = mock(EnvironmentVariableProvider.class); + when(environmentVariableProvider.getenv()).thenReturn(Collections.emptyMap()); + when(environmentVariableProvider.getenv(any())).thenReturn(""); + } + + Path createFile(String filename, String... lines) { + try { + var path = tempDir.resolve(filename); + Files.write(path, List.of(lines), StandardCharsets.UTF_8); + return path; + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Test + void testParseProperties() { + var file = + createFile( + "properties.proj", + "", + " ", + " Blomp", + " Blimp$(FooProp)", + " ", + ""); + var state = new MSBuildParser(file, environmentVariableProvider).parse(); + + assertThat(state.getProperties()) + .containsExactlyInAnyOrderEntriesOf( + Map.of( + "FooProp", "Blomp", + "BarProp", "BlimpBlomp")); + } + + @Test + void testParseItems() { + var file = + createFile( + "items.proj", + "", + " ", + " Blomp", + " Blimp$(FooProp)", + " ", + " ", + " ", + " $(BarProp)", + " Hello world!", + " ", + " ", + ""); + var state = new MSBuildParser(file, environmentVariableProvider).parse(); + + assertThat(state.getItems("BazItem")).hasSize(3); + var item = state.getItems("BazItem").get(1); + assertThat(item.getIdentity()).isEqualTo("Beta"); + assertThat(item.getMetadata("MyMeta")).isEqualTo("BlimpBlomp"); + assertThat(item.getMetadata("MyOtherMeta")).isEqualTo("Hello world!"); + } + + @Test + void testParseSelfReferentialItems() { + var file = + createFile( + "selfReferentialItems.proj", + "", + " ", + " ", + " From %(Identity)", + " Hello %(SignOff)!", + " ", + " ", + ""); + var state = new MSBuildParser(file, environmentVariableProvider).parse(); + + assertThat(state.getItems("BazItem")).hasSize(3); + var item = state.getItems("BazItem").get(1); + assertThat(item.getIdentity()).isEqualTo("Beta"); + assertThat(item.getMetadata("SignOff")).isEqualTo("From Beta"); + assertThat(item.getMetadata("Greeting")).isEqualTo("Hello From Beta!"); + } + + @Test + void testImportProperties() { + createFile( + "imported.proj", + "", + " ", + " $(ChangedProp) - changed by imported file", + " Hello from imported file!", + " ", + ""); + + var mainFile = + createFile( + "importingProject.proj", + "", + " ", + " populated in main file", + " populated in main file - never changed", + " ", + " ", + " ", + " $(ChangedProp) - changed again by main file", + " ", + ""); + + var state = new MSBuildParser(mainFile, environmentVariableProvider).parse(); + assertThat(state.getProperties()) + .containsExactlyInAnyOrderEntriesOf( + Map.of( + "ChangedProp", + "populated in main file - changed by imported file - changed again by main file", + "UnchangedProp", + "populated in main file - never changed", + "ImportedProp", + "Hello from imported file!")); + } + + @Test + void testParseComplexExpressions() { + var file = + createFile( + "complexExpressions.proj", + "", + " ", + " there", + " ", + " ", + " ", + " $(FooProp) %(Identity)", + " Hello %(MyMeta)!", + " ", + " ", + " ", + " @(BazItem->'%(MyOtherMeta)', ':') @(BazItem)", + " ", + ""); + var state = new MSBuildParser(file, environmentVariableProvider).parse(); + + assertThat(state.getProperty("Result")) + .isEqualTo("Hello there Alpha!:Hello there Beta!:Hello there Gamma! Alpha;Beta;Gamma"); + } + + @Test + void testParseWellKnownProperties() { + var file = + createFile( + "wellKnownProperties.proj", + "", + " ", + " Blomp$(MSBuildProjectDirectory)", + " Blimp$(OS)", + " ", + ""); + var state = new MSBuildParser(file, environmentVariableProvider).parse(); + + assertThat(state.getProperties()) + .containsExactlyInAnyOrderEntriesOf( + Map.of( + "FooProp", + String.format("Blomp%s%s", tempDir, FileSystems.getDefault().getSeparator()), + "BarProp", + "BlimpWindows_NT")); + } + + @Test + void testParseThisFileWellKnownProperties() { + createFile( + "wellKnownImportedFile.proj", + "", + " ", + " $(ProjectMsg), imported file project is" + + " $(MSBuildProjectName)", + " $(ThisFileMsg), imported file is $(MSBuildThisFileName)", + " ", + ""); + + var mainFile = + createFile( + "wellKnownMainFile.proj", + "", + " ", + " Main file project is $(MSBuildProjectName)", + " Main file is $(MSBuildThisFileName)", + " ", + " ", + ""); + + var state = new MSBuildParser(mainFile, environmentVariableProvider).parse(); + assertThat(state.getProperty("ProjectMsg")) + .isEqualTo( + "Main file project is wellKnownMainFile, imported file project is wellKnownMainFile"); + assertThat(state.getProperty("ThisFileMsg")) + .isEqualTo("Main file is wellKnownMainFile, imported file is wellKnownImportedFile"); + } + + @Test + void testParseNonexistentImport() { + var mainFile = + createFile( + "fileWithNonexistentImport.proj", + "", + " ", + ""); + + var parser = new MSBuildParser(mainFile, environmentVariableProvider); + assertThatNoException().isThrownBy(parser::parse); + } +} diff --git a/delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/ProjectPropertiesTest.java b/delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/MSBuildStateTest.java similarity index 57% rename from delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/ProjectPropertiesTest.java rename to delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/MSBuildStateTest.java index 6ecc3da7f..619fa0ece 100644 --- a/delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/ProjectPropertiesTest.java +++ b/delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/MSBuildStateTest.java @@ -23,17 +23,13 @@ import static org.mockito.Mockito.when; import au.com.integradev.delphi.enviroment.EnvironmentVariableProvider; -import au.com.integradev.delphi.utils.DelphiUtils; import java.nio.file.Path; import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -class ProjectPropertiesTest { - private static final String ENVIRONMENT_PROJ = - "/au/com/integradev/delphi/msbuild/environment.proj"; - +class MSBuildStateTest { private EnvironmentVariableProvider environmentVariableProvider; @TempDir private Path tempDir; @@ -47,30 +43,23 @@ void init() { @Test void testProjectPropertiesConstructedFromEnvironmentVariables() { - var properties = ProjectProperties.create(environmentVariableProvider, null); + var state = new MSBuildState(tempDir, tempDir, environmentVariableProvider); - assertThat(properties.get("FOO")).isNull(); - assertThat(properties.get("BAR")).isNull(); - assertThat(properties.get("BAZ")).isEqualTo("flarp"); + assertThat(state.getProperty("FOO")).isEmpty(); + assertThat(state.getProperty("BAR")).isEmpty(); + assertThat(state.getProperty("BAZ")).isEqualTo("flarp"); } @Test - void testProjectPropertiesConstructedFromEnvironmentVariablesAndEnvironmentProj() { - Path environmentProj = DelphiUtils.getResource(ENVIRONMENT_PROJ).toPath(); - var properties = ProjectProperties.create(environmentVariableProvider, environmentProj); - - assertThat(properties.get("FOO")).isEqualTo("foo"); - assertThat(properties.get("BAR")).isEqualTo("bar"); - assertThat(properties.get("BAZ")).isEqualTo("flarp"); + void testWellKnownProperties() { + var state = new MSBuildState(tempDir, tempDir, environmentVariableProvider); + assertThat(state.getProperty("MSBuildThisFileFullPath")).isEqualTo(tempDir.toString()); } @Test - void testProjectPropertiesConstructedFromEnvironmentVariablesAndInvalidEnvironmentProj() { - Path environmentProj = tempDir.resolve("does_not_exist.proj"); - var properties = ProjectProperties.create(environmentVariableProvider, environmentProj); - - assertThat(properties.get("FOO")).isNull(); - assertThat(properties.get("BAR")).isNull(); - assertThat(properties.get("BAZ")).isEqualTo("flarp"); + void testOverrideWellKnownProperties() { + var state = new MSBuildState(tempDir, tempDir, environmentVariableProvider); + state.setProperty("MSBuildThisFileFullPath", "bonk"); + assertThat(state.getProperty("MSBuildThisFileFullPath")).isEqualTo("bonk"); } } diff --git a/delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/condition/BinaryExpressionTest.java b/delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/condition/BinaryExpressionTest.java index 77588857c..2a017c53f 100644 --- a/delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/condition/BinaryExpressionTest.java +++ b/delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/condition/BinaryExpressionTest.java @@ -24,14 +24,10 @@ import static org.mockito.Mockito.when; import au.com.integradev.delphi.msbuild.condition.Token.TokenType; -import java.nio.file.Path; -import org.apache.commons.text.StringSubstitutor; +import au.com.integradev.delphi.msbuild.expression.ExpressionEvaluator; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; class BinaryExpressionTest { - @TempDir private Path tempDir; - @Test void testInvalidOperatorShouldThrow() { BinaryExpression expression = @@ -40,10 +36,8 @@ void testInvalidOperatorShouldThrow() { TokenType.END_OF_INPUT, new StringExpression("bar", false)); - StringSubstitutor substitutor = mock(StringSubstitutor.class); - when(substitutor.replace(anyString())).thenAnswer(string -> string); - - ExpressionEvaluator evaluator = new ExpressionEvaluator(tempDir, substitutor); + ExpressionEvaluator evaluator = mock(ExpressionEvaluator.class); + when(evaluator.eval(anyString())).thenAnswer(string -> string); assertThatThrownBy(() -> expression.boolEvaluate(evaluator)) .isInstanceOf(InvalidExpressionException.class); diff --git a/delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/condition/ConditionEvaluatorTest.java b/delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/condition/ConditionEvaluatorTest.java index c74775790..e5a3ba54b 100644 --- a/delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/condition/ConditionEvaluatorTest.java +++ b/delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/condition/ConditionEvaluatorTest.java @@ -25,11 +25,13 @@ import static org.mockito.Mockito.when; import au.com.integradev.delphi.enviroment.EnvironmentVariableProvider; -import au.com.integradev.delphi.msbuild.ProjectProperties; +import au.com.integradev.delphi.msbuild.MSBuildState; import java.nio.file.Path; import java.util.Collections; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; class ConditionEvaluatorTest { @TempDir private Path tempDir; @@ -44,15 +46,41 @@ void testNonBooleanConditionShouldThrow() { assertThatThrownBy(() -> evaluate("'foo'")).isInstanceOf(ConditionEvaluationError.class); } - private static ProjectProperties properties() { + @Test + void testLiteralTrueShouldEvaluateToTrue() { + assertThat(evaluate("true")).isTrue(); + } + + @Test + void testLiteralFalseShouldEvaluateToFalse() { + assertThat(evaluate("false")).isFalse(); + } + + @ParameterizedTest + @ValueSource( + strings = { + "'$(TrueProp)'=='true'", + "'$(FooProp)'=='bar'", + "HasTrailingSlash('foo/bar/')", + "HasTrailingSlash('foo\\bar\\')", + "'$(FooProp)' != 'foo'" + }) + void testTrueCondition(String condition) { + assertThat(evaluate(condition)).isTrue(); + } + + private MSBuildState state() { var environmentVariableProvider = mock(EnvironmentVariableProvider.class); when(environmentVariableProvider.getenv()).thenReturn(Collections.emptyMap()); when(environmentVariableProvider.getenv(anyString())).thenReturn(null); - return ProjectProperties.create(environmentVariableProvider, null); + var state = new MSBuildState(tempDir, tempDir, environmentVariableProvider); + state.setProperty("TrueProp", "true"); + state.setProperty("FooProp", "bar"); + return state; } private boolean evaluate(String condition) { - var evaluator = new ConditionEvaluator(properties(), tempDir); + var evaluator = new ConditionEvaluator(state()); return evaluator.evaluate(condition); } } diff --git a/delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/condition/ExpressionEvaluationTest.java b/delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/condition/ExpressionEvaluationTest.java index 1f15d2bb1..a6344da5d 100644 --- a/delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/condition/ExpressionEvaluationTest.java +++ b/delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/condition/ExpressionEvaluationTest.java @@ -24,21 +24,25 @@ import static org.mockito.Mockito.when; import au.com.integradev.delphi.enviroment.EnvironmentVariableProvider; -import au.com.integradev.delphi.msbuild.ProjectProperties; +import au.com.integradev.delphi.msbuild.MSBuildState; import au.com.integradev.delphi.msbuild.condition.FunctionCallExpression.ArgumentCountMismatchException; import au.com.integradev.delphi.msbuild.condition.FunctionCallExpression.ScalarFunctionWithMultipleItemsException; import au.com.integradev.delphi.msbuild.condition.FunctionCallExpression.UnknownFunctionException; +import au.com.integradev.delphi.msbuild.expression.ExpressionEvaluator; +import java.nio.file.Path; import java.util.List; import java.util.Map; import java.util.stream.Stream; -import org.apache.commons.io.FileUtils; import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.ArgumentsProvider; import org.junit.jupiter.params.provider.ArgumentsSource; class ExpressionEvaluationTest { + private @TempDir Path tempDir; + static class BooleanArgumentsProvider implements ArgumentsProvider { @Override public Stream provideArguments(ExtensionContext context) { @@ -279,15 +283,14 @@ private static Expression parse(String data) { return parser.parse(tokens); } - private static ProjectProperties properties() { + private MSBuildState state() { var environmentVariableProvider = mock(EnvironmentVariableProvider.class); when(environmentVariableProvider.getenv()).thenReturn(Map.of("foo", "bar")); when(environmentVariableProvider.getenv("foo")).thenReturn("bar"); - return ProjectProperties.create(environmentVariableProvider, null); + return new MSBuildState(tempDir, tempDir, environmentVariableProvider); } - private static ExpressionEvaluator expressionEvaluator() { - return new ExpressionEvaluator( - FileUtils.getTempDirectory().toPath(), properties().substitutor()); + private ExpressionEvaluator expressionEvaluator() { + return new ExpressionEvaluator(state()); } } diff --git a/delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/expression/ExpressionEvaluatorTest.java b/delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/expression/ExpressionEvaluatorTest.java new file mode 100644 index 000000000..df960fe2a --- /dev/null +++ b/delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/expression/ExpressionEvaluatorTest.java @@ -0,0 +1,136 @@ +/* + * Sonar Delphi Plugin + * Copyright (C) 2025 Integrated Application Development + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02 + */ +package au.com.integradev.delphi.msbuild.expression; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import au.com.integradev.delphi.enviroment.EnvironmentVariableProvider; +import au.com.integradev.delphi.msbuild.MSBuildItem; +import au.com.integradev.delphi.msbuild.MSBuildState; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class ExpressionEvaluatorTest { + private @TempDir Path tempDir; + + private MSBuildState state() { + var environmentVariableProvider = mock(EnvironmentVariableProvider.class); + when(environmentVariableProvider.getenv()).thenReturn(Collections.emptyMap()); + when(environmentVariableProvider.getenv(anyString())).thenReturn(null); + return new MSBuildState(tempDir, tempDir, environmentVariableProvider); + } + + @Test + void testSimpleString() { + assertThat(new ExpressionEvaluator(state()).eval("hello world")).isEqualTo("hello world"); + } + + @Test + void testProperties() { + MSBuildState state = state(); + state.setProperty("foo", "I am a foo property!"); + state.setProperty("bar", "I am a bar property!"); + assertThat(new ExpressionEvaluator(state).eval("hello $(foo) $(BAR) $(baz)")) + .isEqualTo("hello I am a foo property! I am a bar property! "); + } + + @Test + void testWhitespacedProperties() { + MSBuildState state = state(); + state.setProperty("foo", "I am a foo property!"); + assertThat(new ExpressionEvaluator(state).eval("[$(foo)]<$( foo )>")) + .isEqualTo("[I am a foo property!]<>"); + } + + @Test + void testItems() { + MSBuildState state = state(); + state.addItems( + "MyItem", + List.of( + new MSBuildItem("foo/bar/baz", "C:/project", Collections.emptyMap()), + new MSBuildItem("bar/flarp", "C:/project", Collections.emptyMap()), + new MSBuildItem("bonk", "C:/project", Collections.emptyMap()))); + assertThat(new ExpressionEvaluator(state).eval("hello @(MyItem) world")) + .isEqualTo("hello foo/bar/baz;bar/flarp;bonk world"); + } + + @Test + void testItemsWithCustomSeparators() { + MSBuildState state = state(); + state.addItems( + "MyItem", + List.of( + new MSBuildItem("foo/bar/baz", "C:/project", Collections.emptyMap()), + new MSBuildItem("bar/flarp", "C:/project", Collections.emptyMap()), + new MSBuildItem("bonk", "C:/project", Collections.emptyMap()))); + assertThat(new ExpressionEvaluator(state).eval("hello @(MyItem, ':') world")) + .isEqualTo("hello foo/bar/baz:bar/flarp:bonk world"); + } + + @Test + void testItemTransforms() { + MSBuildState state = state(); + state.addItems( + "MyItem", + List.of( + new MSBuildItem("foo/bar/baz.qux", "C:/project", Collections.emptyMap()), + new MSBuildItem("bar/flarp.qux", "C:/project", Collections.emptyMap()), + new MSBuildItem("bonk", "C:/project", Collections.emptyMap()))); + assertThat(new ExpressionEvaluator(state).eval("hello @(MyItem->'%(Filename)') world")) + .isEqualTo("hello baz;flarp;bonk world"); + } + + @Test + void testNullString() { + assertThat(new ExpressionEvaluator(state()).eval(null)).isNull(); + } + + @Test + void testEmptyString() { + assertThat(new ExpressionEvaluator(state()).eval(" ")).isEqualTo(" "); + } + + @ParameterizedTest + @ValueSource( + strings = { + "$([System.DateTime]::Now)", + "$(MyValue.ToLower())", + "@(Foo->Bar())", + "@(Foo->'%(Bar)'->Baz())", + "$(^)", + "$(MyValue:)", + "@(Foo->)", + "$(!", + "@foo", + "%@$bar)" + }) + void testUnsupportedOrInvalidExpressions(String expression) { + var evaluator = new ExpressionEvaluator(state()); + assertThat(evaluator.eval(expression)).isEqualTo(expression); + } +} diff --git a/delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/expression/MSBuildWellKnownPropertyHelperTest.java b/delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/expression/MSBuildWellKnownPropertyHelperTest.java new file mode 100644 index 000000000..5627588f4 --- /dev/null +++ b/delphi-frontend/src/test/java/au/com/integradev/delphi/msbuild/expression/MSBuildWellKnownPropertyHelperTest.java @@ -0,0 +1,74 @@ +/* + * Sonar Delphi Plugin + * Copyright (C) 2025 Integrated Application Development + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02 + */ +package au.com.integradev.delphi.msbuild.expression; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.ArgumentsProvider; +import org.junit.jupiter.params.provider.ArgumentsSource; + +class MSBuildWellKnownPropertyHelperTest { + + private static class WellKnownPropertyArgumentsProvider implements ArgumentsProvider { + @Override + public Stream provideArguments(ExtensionContext context) { + return Stream.of( + Arguments.of("MSBuildProjectFullPath", "C:\\Source\\Repos\\ConsoleApp1.dproj"), + Arguments.of("MSBuildProjectFile", "ConsoleApp1.dproj"), + Arguments.of("MSBuildProjectName", "ConsoleApp1"), + Arguments.of("MSBuildProjectExtension", ".dproj"), + Arguments.of("MSBuildProjectDirectory", "C:\\Source\\Repos\\"), + Arguments.of("MSBuildProjectDirectoryNoRoot", "Source\\Repos\\"), + Arguments.of("MSBuildThisFileFullPath", "C:\\Source\\Repos\\Props.optset"), + Arguments.of("MSBuildThisFile", "Props.optset"), + Arguments.of("MSBuildThisFileName", "Props"), + Arguments.of("MSBuildThisFileExtension", ".optset"), + Arguments.of("MSBuildThisFileDirectory", "C:\\Source\\Repos\\"), + Arguments.of("MSBuildThisFileDirectoryNoRoot", "Source\\Repos\\"), + Arguments.of("OS", "Windows_NT")); + } + } + + @ParameterizedTest(name = "{0}") + @ArgumentsSource(WellKnownPropertyArgumentsProvider.class) + void testWellKnownProperties(String propertyName, String expected) { + var helper = + new MSBuildWellKnownPropertyHelper( + "C:\\Source\\Repos\\Props.optset", "C:\\Source\\Repos\\ConsoleApp1.dproj"); + assertThat(helper.getProperty(propertyName)).isEqualTo(expected); + } + + @Test + void testCaseInsensitivity() { + var helper = new MSBuildWellKnownPropertyHelper("FOO", "bar"); + assertThat(helper.getProperty("msbuildthisfile")) + .isEqualTo(helper.getProperty("MSBuildThisFile")); + } + + @Test + void testUnknownPropertyReturnsNull() { + var helper = new MSBuildWellKnownPropertyHelper("foo", "bar"); + assertThat(helper.getProperty("foo")).isNull(); + } +} diff --git a/delphi-frontend/src/test/resources/au/com/integradev/delphi/msbuild/ComplexSyntax.optset b/delphi-frontend/src/test/resources/au/com/integradev/delphi/msbuild/ComplexSyntax.optset new file mode 100644 index 000000000..9cbb31fd4 --- /dev/null +++ b/delphi-frontend/src/test/resources/au/com/integradev/delphi/msbuild/ComplexSyntax.optset @@ -0,0 +1,16 @@ + + + subdir + + + + + mysuffix + + + + + WinTypes=Windows;@(Aliases);$(DCC_UnitAlias) + @(OtherDir);$(DCC_UnitSearchPath) + + diff --git a/delphi-frontend/src/test/resources/au/com/integradev/delphi/msbuild/subdir/22.0mysuffix/.gitkeep b/delphi-frontend/src/test/resources/au/com/integradev/delphi/msbuild/subdir/22.0mysuffix/.gitkeep new file mode 100644 index 000000000..e69de29bb