diff --git a/CMake.tmLanguage.json.jinja2 b/CMake.tmLanguage.json.jinja2 new file mode 100644 index 0000000..ecbabcb --- /dev/null +++ b/CMake.tmLanguage.json.jinja2 @@ -0,0 +1,384 @@ +{ + "scopeName": "source.cmake", + "name": "CMake {{ cmake.version() }}", + "fileTypes": ["cmake", "CMakeLists.txt"], + + "repository": { + "builtin-variables": { + "name": "variable.language", + "match": "\\b({{ escape_list_for_regex(cmake.gather_variables()) }})\\b" + }, + + "boolean": { + "name": "constant.language.boolean.cmake", + "match": "\\b(?i:FALSE|NO|OFF|ON|TRUE|YES)\\b" + }, + + "variable-reference": { + "name": "variable.dereference.cmake", + "begin": "\\$\\{", + "beginCaptures": { + "0": { + "name": "punctuation.definition.variable.begin.cmake" + } + }, + "end": "\\}", + "endCaptures": { + "0": { + "name": "punctuation.definition.variable.end.cmake" + } + }, + "patterns": [ + { + "include": "#builtin-variables" + }, + { + "name": "variable.other", + "match": "\\b[A-Za-z_][A-Za-z0-9_]*\\b" + } + ] + }, + + "double-quoted-string": { + "name": "string.quoted.double.cmake", + "begin": "\"", + "beginCaptures": { + "0": { + "name": "punctuation.definition.string.begin.cmake" + } + }, + "end": "\"", + "endCaptures": { + "0": { + "name": "punctuation.definition.string.end.cmake" + } + }, + "patterns": [ + { + "include": "#variable-reference" + }, + { + "name": "constant.character.escape.cmake", + "match": "\\\\." + } + ] + }, + + "default-arguments": { + "patterns": [ + { + "include": "#boolean" + }, + { + "include": "#double-quoted-string" + }, + { + "include": "#variable-reference" + }, + { + "name": "constant.numeric.cmake", + "match": "\\b(0x[0-9a-fA-F]+|\\d+(\\.\\d+)?([eE][+-]?\\d+)?)\\b" + }, + { + "name": "constant.language.null.cmake", + "match": "\\bNULL\\b" + }, + { + "include": "#variable-reference" + }, + { + "include": "#double-quoted-string" + }, + { + "name": "variable.other", + "match": "\\b[A-Za-z_][A-Za-z0-9_]*\\b" + } + ] + }, + + "properties": { + "name": "support.type.property.cmake", + "match": "\\b({{ escape_list_for_regex(cmake.gather_properties()) }})\\b" + }, + + "generator-expression": { + "name": "entity.name.namespace.cmake", + "begin": "\\$\\<({{ escape_list_for_regex(cmake.gather_generator_expressions()) }}):", + "beginCaptures": { + "0": { + "name": "entity.name.namespace.cmake" + } + }, + "end": "[\\>]", + "endCaptures": { + "0": { + "name": "entity.name.namespace.cmake" + } + }, + "patterns": [ + { + "include": "#boolean" + }, + { + "include": "#variable-reference" + }, + { + "include": "#double-quoted-string" + }, + { + "include": "#generatorExpression" + } + ] + }, + "bracket-comment": { + "name": "comment.line.number-sign.bracket.cmake", + "begin": "#\\[(=*)\\[", + "beginCaptures": { "0": { "name": "bracket-comment.begin.cmake" } }, + "end": "\\](=*)\\]", + "endCaptures": { "0": { "name": "bracket-comment.end.cmake" } } + }, + + "line-comment": { + "name": "comment.line.number-sign.cmake", + "match": "#(.*)$", + "captures": { + "1": { + "name": "comment.line.cmake" + } + } + } + }, + + "patterns": [ + + {% for command in cmake.gather_commands() %} + {% set keywords = cmake.gather_command_keywords(command) %} + { + "patterns": [ + { + "name": "support.function.cmake", + "match": "\\b({{ command }})\\b" + }, + { + "name": "meta.group.arguments.cmake", + "begin": "(?<=\\b({{ command }}))\\s*\\(", + "beginCaptures": { + "0": { + "name": "punctuation.section.group.begin.cmake" + } + }, + "end": "\\)", + "endCaptures": { + "0": { + "name": "punctuation.section.group.end.cmake" + } + }, + "patterns": [ + {% if keywords %} + { + "name": "constant.language", + "match": "\\b({{ escape_list_for_regex(keywords) }})\\b" + }, + {% endif %} + {% if "propert" in command %} + { + "include": "#properties" + }, + {% endif %} + { + "include": "#generator-expression" + }, + { + "include": "#default-arguments" + }, + { + "include": "#bracket-comment" + }, + { + "include": "#line-comment" + } + ] + } + ] + }, + {% endfor %} + + {% for module in cmake.modules() %} + + {% set functions = cmake.gather_module_functions(module) %} + {% set keywords = cmake.gather_module_keywords(module) %} + { + "patterns": [ + { + "name": "support.function.cmake", + "match": "(i?)\\b(?i:{{ escape_list_for_regex(functions) }})\\b" + }, + { + "name": "meta.group.arguments.cmake", + "begin": "(?<=\\b(?i:{{ escape_list_for_regex(functions) }}))\\s*\\(", + "beginCaptures": { + "0": { + "name": "punctuation.section.group.begin.cmake" + } + }, + "end": "\\)", + "endCaptures": { + "0": { + "name": "punctuation.section.group.end.cmake" + } + }, + "patterns": [ + {% if keywords %} + { + "name": "constant.language", + "match": "\\b({{ escape_list_for_regex(keywords) }})\\b" + }, + {% endif %} + { + "include": "#generator-expression" + }, + { + "include": "#default-arguments" + }, + { + "include": "#bracket-comment" + }, + { + "include": "#line-comment" + } + ] + } + ] + }, + {% endfor %} + + { + "patterns": [ + { + "name": "keyword.control.cmake", + "match": "\\b(macro|function)\\b" + }, + { + "name": "meta.group.control.cmake", + "begin": "(?<=\\b(macro|function))\\s*\\(", + "beginCaptures": { + "0": { + "name": "punctuation.section.group.begin.cmake" + } + }, + "end": "\\)", + "endCaptures": { + "0": { + "name": "punctuation.section.group.end.cmake" + } + }, + "patterns": [ + { + "name": "entity.name.function", + "match": "\\b[A-Za-z_][A-Za-z0-9_]*\\b" + }, + { + "include": "#bracket-comment" + }, + { + "include": "#line-comment" + } + ] + } + ] + }, + + { + "patterns": [ + { + "name": "keyword.control.cmake", + "match": "\\b(if|elseif|while|{{ escape_list_for_regex(cmake.control_commands()) }})\\b" + }, + { + "name": "meta.group.condition.cmake", + "begin": "(?<=\\b(if|elseif|while))\\s*\\(", + "beginCaptures": { + "0": { + "name": "punctuation.section.group.begin.cmake" + } + }, + "end": "(?=#|$)", + "endCaptures": { + "0": { + "name": "punctuation.section.group.end.cmake" + } + }, + "patterns": [ + { + "name": "keyword.operator.logical.cmake", + "match": "\\b(GREATER|ENV|LESS|POLICY|OR|TARGET|IS_NEWER_THAN|EQUAL|NOT|GREATER_EQUAL|VERSION_LESS_EQUAL|IS_WRITABLE|EXISTS|VERSION_GREATER|IS_DIRECTORY|CACHE|IS_SYMLINK|TEST|MATCHES|IS_READABLE|LESS_EQUAL|STRLESS_EQUAL|IN_LIST|PATH_EQUAL|VERSION_GREATER_EQUAL|STREQUAL|STRGREATER_EQUAL|AND|VERSION_LESS|DEFINED|COMMAND|VERSION_EQUAL|STRLESS|STRGREATER|IS_EXECUTABLE|IS_ABSOLUTE)\\b" + }, + { + "include": "#boolean" + }, + { + "include": "#variable-reference" + }, + { + "include": "#builtin-variables" + }, + { + "include": "#double-quoted-string" + }, + { + "name": "variable.other", + "match": "\\b[A-Za-z_][A-Za-z0-9_]*\\b" + } + ] + } + ] + }, + + { + "patterns": [ + { + "name": "entity.name.function", + "match": "\\b([A-Za-z_][A-Za-z0-9_]*)\\b" + }, + { + "name": "meta.group.condition.cmake", + "begin": "(?<=\\b[A-Za-z_][A-Za-z0-9_]*)\\s*\\(", + "begincaptures": { + "0": { + "name": "punctuation.section.group.begin.cmake" + } + }, + "end": "\\)", + "endcaptures": { + "0": { + "name": "punctuation.section.group.end.cmake" + } + }, + "patterns": [ + { + "include": "#boolean" + }, + { + "include": "#variable-reference" + }, + { + "include": "#double-quoted-string" + }, + { + "name": "variable.other", + "match": "\\b[A-Za-z_][A-Za-z0-9_]*\\b" + } + ] + } + ] + }, + + { + "include": "#bracket-comment" + }, + + { + "include": "#line-comment" + } + ] +} diff --git a/README.md b/README.md index 5229918..2439674 100644 --- a/README.md +++ b/README.md @@ -37,3 +37,26 @@ There is a ever growing test-suite based on ctest located in test/ cd cmake path/to/this/repo/test ctest + + +# textMate grammar + +A TextMate grammar can be generated using the script `textmate-grammar-from-cmake-help.py`. + +This grammar can be used in editors like Visual Studio Code (actually tested with vscode). + +A custom cmake executable can be provided as argument as well as the output-file +(default is `CMake.tmLanguage.json`). + + python3 textmate-grammar-from-cmake-help.py \ + --cmake /path/to/cmake \ + --output /path/to/CMake.tmLanguage.json + +When developing, the script can be run to put the file directly into the vscode-extension +path: + + python3 textmate-grammar-from-cmake-help.py \ + --cmake /path/to/cmake \ + --output ~/.vscode/extensions/ms-vscode.cmake-tools-/syntaxes/CMake.tmLanguage.json + +Then the extension has to be disabled, restarted and enabled again to pick up the new grammar. \ No newline at end of file diff --git a/textmate-grammar-from-cmake-help.py b/textmate-grammar-from-cmake-help.py new file mode 100755 index 0000000..c817404 --- /dev/null +++ b/textmate-grammar-from-cmake-help.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +import argparse +import re +from os.path import abspath, dirname +from typing import List, Set + +from jinja2 import Environment, FileSystemLoader + + +# function to run a process with arguments and return the output +def run_command(command: List[str]): + """ + Run a command with arguments and return the output. + """ + import subprocess + result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + if result.returncode != 0: + raise RuntimeError(f"Command {' '.join(command)} failed with error: {result.stderr}") + return result.stdout.strip() + + +def escape_list_for_regex(var_list: List[str]) -> str: + return "|".join(re.escape(v) for v in sorted(var_list)) + + +class CMakeTextMateGrammarGatherer: + """ + Class to generate a TextMate grammar for CMake based on the output of CMake's help commands. + """ + LANGUAGES = ["C", "CXX", "CSharp", "CUDA", "OBJC", "OBJCXX", "Fortran", "HIP", "ISPC", "Swift", + "ASM", "ASM_NASM", "ASM_MARMASM", "ASM_MASM", "ASM_ATT"] + + def __init__(self, cmake: str = "cmake"): + """ + Initialize the gatherer with the path to the CMake executable. + """ + self.cmake = cmake + + @staticmethod + def _extract_upper(input_text: str) -> Set[str]: + """ + Extract all uppercase words from the input text. + """ + words = set(re.findall(r'\b[A-Z][A-Z_]+\b', input_text)) + + # remove unwanted words + words = {word for word in words if word not in \ + ("VS", "CXX", "IDE", "NOTFOUND", "NO_", "DFOO", "DBAR", "NEW", "GNU")} + + return set(sorted(words)) + + def gather_variables(self): + """ + Gather CMake variables using the `--help-variable-list` command. + """ + output = run_command([self.cmake, "--help-variable-list"]) + + variables = set() + + for var in [line.strip() for line in output.splitlines() if line.strip()]: + if "<" in var and ">" in var: + # Handle language-specific variables + if "" in var: + for lang in self.LANGUAGES: + variables.add(var.replace("", lang)) + # else: + # print(f"Skipping variable with angle brackets: {var}") + else: + variables.add(var) + + return variables + + @staticmethod + def control_commands() -> Set[str]: + """ + Return a set of CMake control commands that do not have arguments. + """ + return {"break", "continue", "return", "else", "endif", "endwhile", "endforeach", "endmacro", + "endfunction"} + + def gather_commands(self): + """ + Gather CMake functions using the `--help-command-list` command. + """ + output = run_command([self.cmake, "--help-command-list"]) + + commands = set() + + for func in [line.strip() for line in output.splitlines() if line.strip()]: + commands.add(func) + + # Remove commands which are handled separately + commands.discard("if") + commands.discard("elseif") + commands.discard("while") + + # commands.discard("foreach") + + commands.discard("macro") + commands.discard("function") + + # Remove commands which have no arguments + commands -= self.control_commands() + + return sorted(commands) + + def gather_command_keywords(self, function: str): + """ + Gather argument keywords for a given CMake command using the `--help-command` command. + """ + output = run_command([self.cmake, "--help-command", function]) + help_text = ' '.join(output.splitlines()) + + # extract all signature lines starting with the command name + signature_pattern = re.compile(rf'\b{function}\b\s*\((.*?)\)', re.DOTALL) + match = signature_pattern.findall(help_text) + if not match: + print(f"No signature found for function: {function}") + return set() + + # extract keywords from the signatures + keywords = set() + for sig in match: + # replace all multiple spaces with a single space to get a nicer signature + sig = ' '.join(sig.split()) + keywords |= self._extract_upper(sig) + + # all_kw = self._extract_upper(help_text) + # if all_kw - keywords: + # print(f"possible additional keywords for {function}", all_kw - keywords) + + return sorted(keywords) + + def gather_generator_expressions(self): + """ + Gather CMake generator expressions using the `--help-manual cmake-generator-expressions` command. + """ + output = run_command([self.cmake, "--help-manual", "cmake-generator-expressions"]) + + help_text = ' '.join(output.splitlines()) + + # find all uppercase words after $< up to : + return sorted(set(re.findall(r'\$<([A-Z_]+):', help_text))) + + def gather_properties(self): + """ + Gather CMake properties using the `--help-property-list` command. + """ + output = run_command([self.cmake, "--help-property-list"]) + + properties = set() + + for prop in [line.strip() for line in output.splitlines() if line.strip()]: + if "<" in prop and ">" in prop: + # Handle language-specific properties + if "" in prop: + for lang in self.LANGUAGES: + properties.add(prop.replace("", lang)) + else: + properties.add(prop) + + return sorted(properties) + + def version(self): + """ + Get the CMake version. + """ + output = run_command([self.cmake, "--version"]) + version_match = re.search(r'cmake version (\d+\.\d+\.\d+)', output) + if version_match: + return version_match.group(1) + else: + raise RuntimeError("Could not determine CMake version") + + def modules(self) -> Set[str]: + """ + For the moment this function returns a hardcoded set of CMake modules. Support by + our gather-function. + """ + # TODO + return {"ExternalProject", "FetchContent", "CMakePackageConfigHelpers"} + + def gather_module_functions(self, module: str) -> Set[str]: + """ + Gather functions from a specific CMake module using the `--help-module` command. + """ + output = run_command([self.cmake, "--help-module", module]) + help_text = ' '.join(output.splitlines()) + + # get all ReST-`.. command::` sections + command_pattern = re.compile(r'\.\. command::\s+([A-Za-z_]+)', re.DOTALL) + matches = command_pattern.findall(help_text) + + if not matches: + raise ValueError(f"No functions found for module: {module}") + + return set(sorted(matches)) + + def gather_module_keywords(self, module: str) -> Set[str]: + """ + Gather keywords for a specific CMake module using the `--help-module` command. + Get all uppercase words from the help text. + """ + output = run_command([self.cmake, "--help-module", module]) + help_text = ' '.join(output.splitlines()) + + return self._extract_upper(help_text) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Generate TextMate grammar for CMake.") + parser.add_argument('--cmake', type=str, default='cmake', + help='Path to the CMake executable (default: cmake)') + parser.add_argument('--output', type=str, default='CMake.tmLanguage.json', + help='Output filename for the TextMate grammar (default: CMake.tmLanguage.json)') + + args = parser.parse_args() + + cmake = CMakeTextMateGrammarGatherer(cmake=args.cmake) + + # Set up Jinja environment with this script's directory as the template loader + script_dir = abspath(dirname(__file__)) + env = Environment(loader=FileSystemLoader(script_dir), trim_blocks=True, lstrip_blocks=True) + + # Expose functions to template + env.globals['cmake'] = cmake + env.globals['escape_list_for_regex'] = escape_list_for_regex + + template = env.get_template('CMake.tmLanguage.json.jinja2') + output = template.render() # print(f" Keywords: {', '.join(keywords)}") + + with open(args.output, 'w') as f: + f.write(output)