Repository: scikit-build/scikit-build-core (python)

Issue:
feat: auto
Several items should support `"auto"` as an option:

* [ ] `cmake.version`: Can read from `CMakeLists.txt`
* [x] `minimum-version`: Can read from `build-system.requires`.

Pull request:
feat: auto cmake version
Close #777.

This is on by default for 0.10+. Setting "CMakeLists.txt" explicitly though will force it to be found, while the default will fallback on 3.15+.

Patch:
diff --git a/README.md b/README.md
index ea5ad0a..da83ca5 100644
--- a/README.md
+++ b/README.md
@@ -154,8 +154,10 @@ print("```\n")
 [tool.scikit-build]
 # The versions of CMake to allow. If CMake is not present on the system or does
 # not pass this specifier, it will be downloaded via PyPI if possible. An empty
-# string will disable this check.
-cmake.version = ">=3.15"
+# string will disable this check. The default on 0.10+ is "CMakeLists.txt",
+# which will read it from the project's CMakeLists.txt file, or ">=3.15" if
+# unreadable or <0.10.
+cmake.version = ""
 
 # A list of args to pass to CMake when configuring the project. Setting this in
 # config or envvar will override toml. See also ``cmake.define``.
diff --git a/docs/api/scikit_build_core.ast.rst b/docs/api/scikit_build_core.ast.rst
new file mode 100644
index 0000000..1131198
--- /dev/null
+++ b/docs/api/scikit_build_core.ast.rst
@@ -0,0 +1,26 @@
+scikit\_build\_core.ast package
+===============================
+
+.. automodule:: scikit_build_core.ast
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+Submodules
+----------
+
+scikit\_build\_core.ast.ast module
+----------------------------------
+
+.. automodule:: scikit_build_core.ast.ast
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+scikit\_build\_core.ast.tokenizer module
+----------------------------------------
+
+.. automodule:: scikit_build_core.ast.tokenizer
+   :members:
+   :undoc-members:
+   :show-inheritance:
diff --git a/docs/api/scikit_build_core.rst b/docs/api/scikit_build_core.rst
index 06044d9..e188934 100644
--- a/docs/api/scikit_build_core.rst
+++ b/docs/api/scikit_build_core.rst
@@ -12,6 +12,7 @@ Subpackages
 .. toctree::
    :maxdepth: 4
 
+   scikit_build_core.ast
    scikit_build_core.build
    scikit_build_core.builder
    scikit_build_core.file_api
diff --git a/docs/api/scikit_build_core.settings.rst b/docs/api/scikit_build_core.settings.rst
index 3277a0e..31e7007 100644
--- a/docs/api/scikit_build_core.settings.rst
+++ b/docs/api/scikit_build_core.settings.rst
@@ -9,6 +9,14 @@ scikit\_build\_core.settings package
 Submodules
 ----------
 
+scikit\_build\_core.settings.auto\_cmake\_version module
+--------------------------------------------------------
+
+.. automodule:: scikit_build_core.settings.auto_cmake_version
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
 scikit\_build\_core.settings.auto\_requires module
 --------------------------------------------------
 
diff --git a/docs/configuration.md b/docs/configuration.md
index 80a0486..6fb8b5e 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -142,6 +142,12 @@ cmake.version = ">=3.26.1"
 ninja.version = ">=1.11"
 ```
 
+You can try to read the version from your CMakeLists.txt with the special
+string `"CMakeLists.txt"`. This is an error if the minimum version was not
+statically detectable in the file. If your `minimum-version` setting is unset
+or set to "0.10" or higher, scikit-build-core will still try to read this if
+possible, and will fall back on ">=3.15" if it can't read it.
+
 You can also enforce ninja to be required even if make is present on Unix:
 
 ```toml
diff --git a/pyproject.toml b/pyproject.toml
index 1084561..1b664a1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -205,6 +205,7 @@ report.exclude_lines = [
     'if typing.TYPE_CHECKING:',
     'if TYPE_CHECKING:',
     'def __repr__',
+    'if __name__ == "main":',
 ]
 
 
diff --git a/src/scikit_build_core/ast/__init__.py b/src/scikit_build_core/ast/__init__.py
new file mode 100644
index 0000000..bdec2fc
--- /dev/null
+++ b/src/scikit_build_core/ast/__init__.py
@@ -0,0 +1,3 @@
+from __future__ import annotations
+
+__all__: list[str] = []
diff --git a/src/scikit_build_core/ast/ast.py b/src/scikit_build_core/ast/ast.py
new file mode 100644
index 0000000..a83beef
--- /dev/null
+++ b/src/scikit_build_core/ast/ast.py
@@ -0,0 +1,95 @@
+from __future__ import annotations
+
+import dataclasses
+import sys
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+from .._logging import rich_print
+from .tokenizer import Token, TokenType, tokenize
+
+if TYPE_CHECKING:
+    from collections.abc import Generator
+
+__all__ = ["Node", "Block", "parse"]
+
+
+def __dir__() -> list[str]:
+    return __all__
+
+
+@dataclasses.dataclass(frozen=True)
+class Node:
+    __slots__ = ("name", "value", "start", "stop")
+
+    name: str
+    value: str
+    start: int
+    stop: int
+
+    def __str__(self) -> str:
+        return f"{self.name}({self.value})"
+
+
+@dataclasses.dataclass(frozen=True)
+class Block(Node):
+    __slots__ = ("contents",)
+
+    contents: list[Node]
+
+    def __str__(self) -> str:
+        return f"{super().__str__()} ... {len(self.contents)} children"
+
+
+def parse(
+    tokens: Generator[Token, None, None], stop: str = ""
+) -> Generator[Node, None, None]:
+    """
+    Generate a stream of nodes from a stream of tokens. This currently bundles all block-like functions
+    into a single `Block` node, but this could be changed to be more specific eventually if needed.
+    """
+    try:
+        while True:
+            token = next(tokens)
+            if token.type != TokenType.UNQUOTED:
+                continue
+            name = token.value.lower()
+            start = token.start
+            token = next(tokens)
+            if token.type == TokenType.WHITESPACE:
+                token = next(tokens)
+            if token.type != TokenType.OPEN_PAREN:
+                msg = f"Expected open paren after {name!r}, got {token!r}"
+                raise AssertionError(msg)
+            count = 1
+            value = ""
+            while True:
+                token = next(tokens)
+                if token.type == TokenType.OPEN_PAREN:
+                    count += 1
+                elif token.type == TokenType.CLOSE_PAREN:
+                    count -= 1
+                    if count == 0:
+                        break
+                value += token.value
+
+            if name in {"if", "foreach", "while", "macro", "function", "block"}:
+                contents = list(parse(tokens, f"end{name}"))
+                yield Block(name, value, start, contents[-1].stop, contents)
+            else:
+                yield Node(name, value, start, token.stop)
+            if stop and name == stop:
+                break
+    except StopIteration:
+        pass
+
+
+if __name__ == "__main__":
+    with Path(sys.argv[1]).open(encoding="utf-8-sig") as f:
+        for node in parse(tokenize(f.read())):
+            cnode = dataclasses.replace(
+                node,
+                name=f"[bold blue]{node.name}[/bold /blue]",
+                value=f"[green]{node.value}[/green]",
+            )
+            rich_print(cnode)
diff --git a/src/scikit_build_core/ast/tokenizer.py b/src/scikit_build_core/ast/tokenizer.py
new file mode 100644
index 0000000..556c1f7
--- /dev/null
+++ b/src/scikit_build_core/ast/tokenizer.py
@@ -0,0 +1,81 @@
+from __future__ import annotations
+
+import dataclasses
+import enum
+import re
+import sys
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+from .._logging import rich_print
+
+if TYPE_CHECKING:
+    from collections.abc import Generator
+
+__all__ = ["Token", "TokenType", "tokenize"]
+
+
+def __dir__() -> list[str]:
+    return __all__
+
+
+TOKEN_EXPRS = {
+    "BRACKET_COMMENT": r"\s*#\[(?P<bc1>=*)\[(?s:.)*?\](?P=bc1)\]",
+    "COMMENT": r"#.*$",
+    "QUOTED": r'"(?:\\(?s:.)|[^"\\])*?"',
+    "BRACKET_QUOTE": r"\[(?P<bq1>=*)\[(?s:.)*?\](?P=bq1)\]",
+    "OPEN_PAREN": r"\(",
+    "CLOSE_PAREN": r"\)",
+    "LEGACY": r'\b\w+=[^\s"()$\\]*(?:"[^"\\]*"[^\s"()$\\]*)*|"(?:[^"\\]*(?:\\.[^"\\]*)*)*"',
+    "UNQUOTED": r"(?:\\.|[^\s()#\"\\])+",
+}
+
+
+class TokenType(enum.Enum):
+    BRACKET_COMMENT = enum.auto()
+    COMMENT = enum.auto()
+    UNQUOTED = enum.auto()
+    QUOTED = enum.auto()
+    BRACKET_QUOTE = enum.auto()
+    LEGACY = enum.auto()
+    OPEN_PAREN = enum.auto()
+    CLOSE_PAREN = enum.auto()
+    WHITESPACE = enum.auto()
+
+
+@dataclasses.dataclass(frozen=True)
+class Token:
+    __slots__ = ("type", "start", "stop", "value")
+
+    type: TokenType
+    start: int
+    stop: int
+    value: str
+
+    def __str__(self) -> str:
+        return f"{self.type.name}({self.value!r})"
+
+
+def tokenize(contents: str) -> Generator[Token, None, None]:
+    tok_regex = "|".join(f"(?P<{n}>{v})" for n, v in TOKEN_EXPRS.items())
+    last = 0
+    for match in re.finditer(tok_regex, contents, re.MULTILINE):
+        for typ, value in match.groupdict().items():
+            if typ in TOKEN_EXPRS and value is not None:
+                if match.start() != last:
+                    yield Token(
+                        TokenType.WHITESPACE,
+                        last,
+                        match.start(),
+                        contents[last : match.start()],
+                    )
+                last = match.end()
+                yield Token(TokenType[typ], match.start(), match.end(), value)
+
+
+if __name__ == "__main__":
+    with Path(sys.argv[1]).open(encoding="utf-8-sig") as f:
+        for token in tokenize(f.read()):
+            rich_print(
+                f"[green]{token.type.name}[/green][red]([/red]{token.value}[red])[/red]"
+            )
diff --git a/src/scikit_build_core/resources/scikit-build.schema.json b/src/scikit_build_core/resources/scikit-build.schema.json
index 25459ce..4fab59f 100644
--- a/src/scikit_build_core/resources/scikit-build.schema.json
+++ b/src/scikit_build_core/resources/scikit-build.schema.json
@@ -16,8 +16,7 @@
         },
         "version": {
           "type": "string",
-          "default": ">=3.15",
-          "description": "The versions of CMake to allow. If CMake is not present on the system or does not pass this specifier, it will be downloaded via PyPI if possible. An empty string will disable this check."
+          "description": "The versions of CMake to allow. If CMake is not present on the system or does not pass this specifier, it will be downloaded via PyPI if possible. An empty string will disable this check. The default on 0.10+ is \"CMakeLists.txt\", which will read it from the project's CMakeLists.txt file, or \">=3.15\" if unreadable or <0.10."
         },
         "args": {
           "type": "array",
diff --git a/src/scikit_build_core/settings/auto_cmake_version.py b/src/scikit_build_core/settings/auto_cmake_version.py
new file mode 100644
index 0000000..e112da5
--- /dev/null
+++ b/src/scikit_build_core/settings/auto_cmake_version.py
@@ -0,0 +1,27 @@
+from __future__ import annotations
+
+from ..ast.ast import parse
+from ..ast.tokenizer import tokenize
+
+__all__ = ["find_min_cmake_version"]
+
+
+def __dir__() -> list[str]:
+    return __all__
+
+
+def find_min_cmake_version(cmake_content: str) -> str | None:
+    """
+    Locate the minimum required version. Return None if not found.
+    """
+    for node in parse(tokenize(cmake_content)):
+        if node.name == "cmake_minimum_required":
+            return (
+                node.value.replace("VERSION", "")
+                .replace("FATAL_ERROR", "")
+                .split("...")[0]
+                .strip()
+                .strip("\"'[]=")
+            )
+
+    return None
diff --git a/src/scikit_build_core/settings/skbuild_model.py b/src/scikit_build_core/settings/skbuild_model.py
index 4dd4c81..09bdf72 100644
--- a/src/scikit_build_core/settings/skbuild_model.py
+++ b/src/scikit_build_core/settings/skbuild_model.py
@@ -33,11 +33,13 @@ class CMakeSettings:
     DEPRECATED in 0.8; use version instead.
     """
 
-    version: SpecifierSet = SpecifierSet(">=3.15")
+    version: Optional[SpecifierSet] = None
     """
-    The versions of CMake to allow. If CMake is not present on the system or does
-    not pass this specifier, it will be downloaded via PyPI if possible. An empty
-    string will disable this check.
+    The versions of CMake to allow. If CMake is not present on the system or
+    does not pass this specifier, it will be downloaded via PyPI if possible. An
+    empty string will disable this check. The default on 0.10+ is
+    "CMakeLists.txt", which will read it from the project's CMakeLists.txt file,
+    or ">=3.15" if unreadable or <0.10.
     """
 
     args: List[str] = dataclasses.field(default_factory=list)
diff --git a/src/scikit_build_core/settings/skbuild_read_settings.py b/src/scikit_build_core/settings/skbuild_read_settings.py
index 55115d1..ea6e526 100644
--- a/src/scikit_build_core/settings/skbuild_read_settings.py
+++ b/src/scikit_build_core/settings/skbuild_read_settings.py
@@ -17,6 +17,7 @@ from .. import __version__
 from .._compat import tomllib
 from .._logging import logger, rich_print
 from ..errors import CMakeConfigError
+from .auto_cmake_version import find_min_cmake_version
 from .auto_requires import get_min_requires
 from .skbuild_model import CMakeSettings, NinjaSettings, ScikitBuildSettings
 from .sources import ConfSource, EnvSource, SourceChain, TOMLSource
@@ -154,7 +155,9 @@ def override_match(
 
 
 def _handle_minimum_version(
-    dc: CMakeSettings | NinjaSettings, minimum_version: Version | None
+    dc: CMakeSettings | NinjaSettings,
+    minimum_version: Version | None,
+    default: str = "",
 ) -> None:
     """
     Handle the minimum version option. Supports scikit-build-core < 0.8 style
@@ -165,6 +168,11 @@ def _handle_minimum_version(
     version_default = next(
         iter(f for f in dataclasses.fields(dc) if f.name == "version")
     ).default
+    if version_default is None:
+        if not default:
+            msg = "Default version must be provided for this function if None is the default"
+            raise AssertionError(msg)
+        version_default = SpecifierSet(f">={default}")
 
     # Check for minimum_version < 0.8 and the modern version setting
     if (
@@ -343,12 +351,24 @@ class SettingsReader:
             min_v = get_min_requires("scikit-build-core", reqlist)
             if min_v is None:
                 rich_print(
-                    "[red][bold]ERROR:[/bold] scikit-build-core needs a min version in build-system.requires to use minimum-version='build-system.requires'"
+                    "[red][bold]ERROR:[/bold] scikit-build-core needs a min version in "
+                    "build-system.requires to use minimum-version='build-system.requires'"
                 )
                 raise SystemExit(7)
             pyproject["tool"]["scikit-build"]["minimum-version"] = str(min_v)
         toml_srcs = [TOMLSource("tool", "scikit-build", settings=pyproject)]
 
+        # Support for cmake.version='CMakeLists.txt'
+        # We will save the value for now since we need the CMakeLists location
+        force_auto_cmake = (
+            pyproject.get("tool", {})
+            .get("scikit-build", {})
+            .get("cmake", {})
+            .get("version", None)
+        ) == "CMakeLists.txt"
+        if force_auto_cmake:
+            del pyproject["tool"]["scikit-build"]["cmake"]["version"]
+
         if extra_settings is not None:
             extra_skb = copy.deepcopy(dict(extra_settings))
             process_overides(extra_skb, state, env)
@@ -399,7 +419,53 @@ class SettingsReader:
         if self.settings.install.strip is None:
             self.settings.install.strip = install_policy
 
-        _handle_minimum_version(self.settings.cmake, self.settings.minimum_version)
+        # Before 0.10, we hard-coded 3.15+ as the minimum CMake version
+        if (
+            self.settings.cmake.version is None
+            and self.settings.minimum_version is not None
+            and self.settings.minimum_version < Version("0.10")
+        ):
+            self.settings.cmake.version = SpecifierSet(">=3.15")
+
+        # If we noted earlier that auto-cmake was requested, handle it now
+        if self.settings.cmake.version is None:
+            cmake_path = self.settings.cmake.source_dir / "CMakeLists.txt"
+            try:
+                with cmake_path.open(encoding="utf-8-sig") as f:
+                    new_min_cmake = find_min_cmake_version(f.read())
+            except FileNotFoundError:
+                new_min_cmake = "3.15"
+                rich_print(
+                    "[red][bold]WARNING:[/bold] CMakeLists.txt not found when looking for minimum CMake version. "
+                    "Report this or (and) set manually to avoid this warning. Using 3.15 as a fall-back."
+                )
+
+            if new_min_cmake is None:
+                if force_auto_cmake:
+                    rich_print(
+                        "[red][bold]ERROR:[/bold] Minimum CMake version set as "
+                        "'CMakeLists.txt' wasn't able to find minimum version setting. "
+                        "If the CMakeLists.txt is valid, this might be a bug in our search algorithm."
+                    )
+                    raise SystemExit(7)
+                rich_print(
+                    "[red][bold]WARNING:[/bold] Minimum CMake version not found in CMakeLists.txt. "
+                    "If the CMakeLists.txt is valid, this might be a bug in our search algorithm. Report "
+                    "this or (and) set manually to avoid this warning."
+                )
+                new_min_cmake = "3.15"
+            if Version(new_min_cmake) < Version("3.15"):
+                rich_print(
+                    "[red][bold]WARNING:[/bold] Minimum CMake version set as "
+                    "'CMakeLists.txt' is less than 3.15. "
+                    "This is not supported by scikit-build-core; set manually or increase to avoid this warning."
+                )
+                new_min_cmake = "3.15"
+            self.settings.cmake.version = SpecifierSet(f">={new_min_cmake}")
+
+        _handle_minimum_version(
+            self.settings.cmake, self.settings.minimum_version, "3.15"
+        )
         _handle_minimum_version(self.settings.ninja, self.settings.minimum_version)
 
         self.settings.build.verbose = _handle_move(


Test patch:
diff --git a/tests/packages/fortran_example/pyproject.toml b/tests/packages/fortran_example/pyproject.toml
index b47476c..2b89b26 100644
--- a/tests/packages/fortran_example/pyproject.toml
+++ b/tests/packages/fortran_example/pyproject.toml
@@ -11,3 +11,6 @@ classifiers = [
     "License :: OSI Approved :: BSD License",
 ]
 dependencies = ["numpy"]
+
+[tool.scikit-build]
+cmake.version = "CMakeLists.txt"
diff --git a/tests/test_auto.py b/tests/test_auto.py
index d3b3abb..3133a7a 100644
--- a/tests/test_auto.py
+++ b/tests/test_auto.py
@@ -1,6 +1,9 @@
+import textwrap
+
 import pytest
 from packaging.version import Version
 
+from scikit_build_core.settings.auto_cmake_version import find_min_cmake_version
 from scikit_build_core.settings.auto_requires import get_min_requires
 
 
@@ -25,3 +28,53 @@ def test_auto_requires_pkg_no_spec():
 def test_auto_requires_pkg_version(spec: str, version: Version):
     reqlist = [f"scikit_build_core{spec}"]
     assert get_min_requires("scikit-build-core", reqlist) == version
+
+
+@pytest.mark.parametrize(
+    ("expr", "answer"),
+    [
+        ("3.15", "3.15"),
+        ("3.16", "3.16"),
+        ("3.17.2", "3.17.2"),
+        ("3.18...3.29", "3.18"),
+        ("3.19.2...3.29", "3.19.2"),
+    ],
+)
+def test_auto_cmake_version(expr: str, answer: str):
+    txt = f"stuff()\ncmake_minimum_required(VERSION {expr})\nother()"
+    res = find_min_cmake_version(txt)
+    assert res == answer
+    txt = f"stuff()\n# cmake_minimum_version(VERSION 3.1)\ncmake_minimum_required(\nVERSION  {expr}  FATAL_ERROR)\nother()"
+    res = find_min_cmake_version(txt)
+    assert res == answer
+
+
+@pytest.mark.parametrize(
+    "block",
+    [
+        "if",
+        "foreach",
+        "while",
+        "macro",
+        "function",
+        "block",
+    ],
+)
+def test_auto_cmake_version_block(block: str):
+    txt = textwrap.dedent(f"""\
+        # cmake_minimum_version(VERSION 3.1)
+                          
+        #[[
+        cmake_minimum_required(VERSION 3.2)
+        ]]
+        
+        {block}()
+        cmake_minimum_required(VERSION 3.3) 
+        end{block}()
+
+        cmake_MINimum_required(VERSION 3.4)
+
+        cmake_minimum_required(VERSION 3.5) 
+    """)
+    res = find_min_cmake_version(txt)
+    assert res == "3.4"
diff --git a/tests/test_cmake_ast.py b/tests/test_cmake_ast.py
new file mode 100644
index 0000000..7fc8e4c
--- /dev/null
+++ b/tests/test_cmake_ast.py
@@ -0,0 +1,138 @@
+import textwrap
+from pathlib import Path
+
+import pytest
+
+from scikit_build_core.ast.ast import parse
+from scikit_build_core.ast.tokenizer import tokenize
+
+DIR = Path(__file__).parent.absolute()
+
+FILENAMES = [
+    *DIR.joinpath("packages").rglob("**/CMakeLists.txt"),
+    *DIR.parent.joinpath("docs/examples").rglob("**/CMakeLists.txt"),
+]
+IDS = [str(p.relative_to(DIR.parent).parent) for p in FILENAMES]
+
+
+@pytest.mark.parametrize("filename", FILENAMES, ids=IDS)
+def test_cmake_file_parse(filename: Path):
+    for x in parse(tokenize(filename.read_text(encoding="utf-8-sig"))):
+        assert str(x).startswith(f"{x.name}({x.value})")
+
+
+def test_cmake_ast_parse():
+    txt = textwrap.dedent("""\
+
+        # [[[ Not a block comment
+        cmake_minimum_required(VERSION 3.25...3.30)
+        # ]]] Not a block comment
+
+        #[[ A block comment
+        invalid syntax
+        ]]
+
+        if(True)
+            block()
+                my_function()
+            endblock()
+        endif()
+
+        """)
+
+    ast = list(parse(tokenize(txt)))
+
+    assert ast[0].name == "cmake_minimum_required"
+    assert ast[1].name == "if"
+    assert ast[1].contents[0].name == "block"  # type: ignore[attr-defined]
+    assert ast[1].contents[0].contents[0].name == "my_function"  # type: ignore[attr-defined]
+
+
+def test_cmake_ast_parse_long():
+    txt = textwrap.dedent(
+        """\
+        # CMakeLists.txt - Example for Tokenization
+
+        cmake_minimum_required(VERSION 3.10)
+
+        # Set the project name
+        project(TokenizationExample VERSION 1.0)
+
+        # Include directories
+        include_directories(
+            ${PROJECT_SOURCE_DIR}/include
+            ${PROJECT_SOURCE_DIR}/external
+            [[
+            Multiline
+            string
+            ]]
+        )
+
+        # Add executable
+        add_executable(TokenizationExample 
+            src/main.cpp 
+            src/utils.cpp
+        )
+
+        # Set C++ standard
+          set(CMAKE_CXX_STANDARD 11)
+          set(CMAKE_CXX_STANDARD_REQUIRED True)
+
+        # Link libraries
+        target_link_libraries(TokenizationExample
+            PUBLIC
+            SomeLibrary
+            AnotherLibrary
+        )
+
+        #[[ Block comment example
+
+        This is a block comment.
+        It spans multiple lines.
+        It is used to provide more detailed explanations
+        or to temporarily disable code.
+        ]]
+
+        # Multiline string example
+        set(MULTILINE_STRING "This is a multiline string \
+        that spans multiple lines. \
+        It is often used for setting large chunks of text \
+        or configurations.")
+
+        # Multiline function call example
+        add_library(MyLibrary
+            ${PROJECT_SOURCE_DIR}/src/lib.cpp
+            ${PROJECT_SOURCE_DIR}/src/helper.cpp
+            ${PROJECT_SOURCE_DIR}/src/another_helper.cpp
+        )
+
+        #[==[ Another block comment example
+
+        The following block of code adds a custom target
+        and a custom command to run some script.
+        It is useful for adding pre-build or post-build steps.
+
+        ]=] and ]] and ]===] can't confuse it.
+
+        function(not real)
+
+        ]==]
+
+
+        if(DEFINED VALUE)
+            add_custom_target(RunScript ALL
+                COMMAND ${CMAKE_COMMAND} -E echo "Running custom script"
+                COMMAND ${CMAKE_COMMAND} -E touch ${PROJECT_BINARY_DIR}/dummy_file
+            ) # trailing comment
+
+            # Set properties for the custom target
+            set_target_properties(RunScript PROPERTIES
+                COMMENT "This target runs a custom script."
+            )
+        endif()
+
+        # End of CMakeLists.txt
+        """
+    )
+    for _ in parse(tokenize(txt)):
+        pass
diff --git a/tests/test_skbuild_settings.py b/tests/test_skbuild_settings.py
index 4fc8be4..2d76947 100644
--- a/tests/test_skbuild_settings.py
+++ b/tests/test_skbuild_settings.py
@@ -336,13 +336,17 @@ def test_skbuild_settings_pyproject_toml(
 
 
 def test_skbuild_settings_pyproject_toml_broken(
-    tmp_path: Path, capsys: pytest.CaptureFixture[str]
+    tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
 ):
+    monkeypatch.setattr(
+        scikit_build_core.settings.skbuild_read_settings, "__version__", "0.9.0"
+    )
     pyproject_toml = tmp_path / "pyproject.toml"
     pyproject_toml.write_text(
         textwrap.dedent(
             """\
             [tool.scikit-build]
+            minimum-version = "0.9"
             cmake.verison = ">=3.18"
             ninja.version = ">=1.3"
             ninja.make-fallback = false
@@ -375,9 +379,16 @@ def test_skbuild_settings_pyproject_toml_broken(
     )
 
 
-def test_skbuild_settings_pyproject_conf_broken(tmp_path, capsys):
+def test_skbuild_settings_pyproject_conf_broken(
+    tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
+):
+    monkeypatch.setattr(
+        scikit_build_core.settings.skbuild_read_settings, "__version__", "0.9.0"
+    )
     pyproject_toml = tmp_path / "pyproject.toml"
-    pyproject_toml.write_text("", encoding="utf-8")
+    pyproject_toml.write_text(
+        "tool.scikit-build.minimum-version = '0.9'", encoding="utf-8"
+    )
 
     config_settings: dict[str, str | list[str]] = {
         "cmake.verison": ">=3.17",
@@ -599,3 +610,114 @@ def test_auto_minimum_version(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
 
     reader = SettingsReader.from_file(pyproject_toml, {})
     assert reader.settings.minimum_version == Version("0.8")
+
+
+def test_auto_cmake_version(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
+    pyproject_toml = tmp_path / "pyproject.toml"
+    pyproject_toml.write_text(
+        textwrap.dedent(
+            """\
+            [build-system]
+            requires = ["scikit-build-core>=0.8"]
+
+            [tool.scikit-build]
+            cmake.version = "CMakeLists.txt"
+            """
+        ),
+        encoding="utf-8",
+    )
+    cmakelists_txt = tmp_path / "CMakeLists.txt"
+    cmakelists_txt.write_text(
+        textwrap.dedent(
+            """\
+            cmake_minimum_required(VERSION 3.21)
+            """
+        ),
+        encoding="utf-8",
+    )
+    monkeypatch.chdir(tmp_path)
+
+    reader = SettingsReader.from_file(pyproject_toml, {})
+    assert reader.settings.cmake.version == SpecifierSet(">=3.21")
+
+
+@pytest.mark.parametrize("version", ["0.9", "0.10"])
+def test_default_auto_cmake_version(
+    version: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+):
+    monkeypatch.setattr(
+        scikit_build_core.settings.skbuild_read_settings, "__version__", "0.10.0"
+    )
+    pyproject_toml = tmp_path / "pyproject.toml"
+    pyproject_toml.write_text(
+        textwrap.dedent(
+            f"""\
+            [build-system]
+            requires = ["scikit-build-core>={version}"]
+
+            [tool.scikit-build]
+            minimum-version = "build-system.requires"
+            """
+        ),
+        encoding="utf-8",
+    )
+    cmakelists_txt = tmp_path / "CMakeLists.txt"
+    cmakelists_txt.write_text(
+        textwrap.dedent(
+            """\
+            cmake_minimum_required(VERSION 3.21)
+            """
+        ),
+        encoding="utf-8",
+    )
+    monkeypatch.chdir(tmp_path)
+
+    reader = SettingsReader.from_file(pyproject_toml, {})
+    assert reader.settings.cmake.version == SpecifierSet(
+        ">=3.21" if version == "0.10" else ">=3.15"
+    )
+
+
+def test_skbuild_settings_auto_cmake_warning(
+    tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
+):
+    monkeypatch.setattr(
+        scikit_build_core.settings.skbuild_read_settings, "__version__", "0.10.0"
+    )
+    pyproject_toml = tmp_path / "pyproject.toml"
+    pyproject_toml.write_text(
+        textwrap.dedent(
+            """\
+            [tool.scikit-build]
+            minimum-version = "0.10"
+            """
+        ),
+        encoding="utf-8",
+    )
+
+    cmakelists_txt = tmp_path / "CMakeLists.txt"
+    cmakelists_txt.write_text(
+        textwrap.dedent(
+            """\
+            cmake_minimum_required(VERSION 3.14)
+            """
+        ),
+        encoding="utf-8",
+    )
+
+    config_settings: dict[str, list[str] | str] = {}
+
+    settings_reader = SettingsReader.from_file(pyproject_toml, config_settings)
+
+    assert settings_reader.settings.cmake.version == SpecifierSet(">=3.15")
+
+    ex = capsys.readouterr().out
+    ex = re.sub(r"\x1b(\[.*?[@-~]|\].*?(\x07|\x1b\\))", "", ex)
+    print(ex)
+    assert (
+        ex.split()
+        == """\
+            WARNING: CMakeLists.txt not found when looking for minimum CMake version.
+            Report this or (and) set manually to avoid this warning. Using 3.15 as a fall-back.
+      """.split()
+    )