From 9f2b8954e9a787dd74271186fe2930e14e2d2e8e Mon Sep 17 00:00:00 2001 From: Alibek Omarov Date: Tue, 30 Jun 2026 23:40:01 +0500 Subject: [PATCH] utils: add new tool xash-clang-format, a wrapper around clang-format that adds our formatting extensions --- .clang-format | 113 +++++ scripts/waifulib/clang_format.py | 119 ++++++ utils/xash-clang-format/config.c | 91 ++++ utils/xash-clang-format/main.c | 402 ++++++++++++++++++ utils/xash-clang-format/proc.c | 99 +++++ utils/xash-clang-format/tests/test_config.c | 77 ++++ .../xash-clang-format/tests/test_transform.c | 171 ++++++++ utils/xash-clang-format/tests/test_version.c | 58 +++ utils/xash-clang-format/transform.c | 325 ++++++++++++++ utils/xash-clang-format/version.c | 67 +++ utils/xash-clang-format/wscript | 42 ++ utils/xash-clang-format/xcf.h | 57 +++ wscript | 29 +- 13 files changed, 1647 insertions(+), 3 deletions(-) create mode 100644 .clang-format create mode 100644 scripts/waifulib/clang_format.py create mode 100644 utils/xash-clang-format/config.c create mode 100644 utils/xash-clang-format/main.c create mode 100644 utils/xash-clang-format/proc.c create mode 100644 utils/xash-clang-format/tests/test_config.c create mode 100644 utils/xash-clang-format/tests/test_transform.c create mode 100644 utils/xash-clang-format/tests/test_version.c create mode 100644 utils/xash-clang-format/transform.c create mode 100644 utils/xash-clang-format/version.c create mode 100644 utils/xash-clang-format/wscript create mode 100644 utils/xash-clang-format/xcf.h diff --git a/.clang-format b/.clang-format new file mode 100644 index 00000000..1a00a0eb --- /dev/null +++ b/.clang-format @@ -0,0 +1,113 @@ +Language: Cpp +Standard: c++17 +UseTab: ForContinuationAndIndentation +TabWidth: 4 +IndentWidth: 4 +ContinuationIndentWidth: 4 +IndentCaseLabels: false +IndentPPDirectives: None +NamespaceIndentation: None +AccessModifierOffset: -4 +IndentAccessModifiers: false +BreakBeforeBraces: Custom +BraceWrapping: + AfterCaseLabel: true + AfterClass: true + AfterControlStatement: Always + AfterEnum: true + AfterFunction: true + AfterNamespace: true + AfterStruct: true + AfterUnion: true + AfterExternBlock: false + BeforeCatch: true + BeforeElse: true + BeforeWhile: true + IndentBraces: false + SplitEmptyFunction: false + SplitEmptyRecord: false + SplitEmptyNamespace: false +MaxEmptyLinesToKeep: 2 +KeepEmptyLinesAtTheStartOfBlocks: false +KeepEmptyLinesAtEOF: false +SeparateDefinitionBlocks: Always +InsertNewlineAtEOF: true +LineEnding: LF +AllowAllArgumentsOnNextLine: true +AllowAllParametersOfDeclarationOnNextLine: true +BinPackArguments: true +BinPackParameters: true +AlwaysBreakAfterReturnType: None +AlwaysBreakAfterDefinitionReturnType: None +AllowShortFunctionsOnASingleLine: None +AllowShortBlocksOnASingleLine: Empty +AllowShortCaseLabelsOnASingleLine: false +AllowShortIfStatementsOnASingleLine: Never +AllowShortLoopsOnASingleLine: false +AllowShortEnumsOnASingleLine: false +BreakBeforeBinaryOperators: All +BreakBeforeTernaryOperators: true +BreakConstructorInitializers: BeforeColon +PointerAlignment: Right +DerivePointerAlignment: false +ReferenceAlignment: Pointer +SpaceAroundPointerQualifiers: Default +SpacesInParens: Custom +SpacesInParensOptions: + ExceptDoubleParentheses: true + InCStyleCasts: false + InConditionalStatements: true + InEmptyParentheses: false + Other: true +# XashCollapseParens: true +SpaceBeforeParens: Never +SpaceAfterCStyleCast: false +SpaceAfterLogicalNot: false +SpaceAfterTemplateKeyword: true +SpaceBeforeAssignmentOperators: true +SpaceBeforeCaseColon: false +SpaceBeforeCpp11BracedList: false +SpaceBeforeCtorInitializerColon: true +SpaceBeforeInheritanceColon: true +SpaceBeforeRangeBasedForLoopColon: true +SpaceBeforeSquareBrackets: false +SpaceInEmptyBlock: false +SpacesInAngles: Never +SpacesInContainerLiterals: false +SpacesInSquareBrackets: false +Cpp11BracedListStyle: false +SpacesBeforeTrailingComments: 1 +SpacesInLineCommentPrefix: + Minimum: 1 + Maximum: -1 +AlignAfterOpenBracket: DontAlign +AlignArrayOfStructures: None +AlignConsecutiveAssignments: + Enabled: false + AcrossEmptyLines: false + AcrossComments: false +AlignConsecutiveBitFields: + Enabled: false + AcrossEmptyLines: false + AcrossComments: false +AlignConsecutiveDeclarations: + Enabled: false + AcrossEmptyLines: false + AcrossComments: false + AlignCompound: false +AlignConsecutiveMacros: + Enabled: true + AcrossEmptyLines: false + AcrossComments: false +AlignEscapedNewlines: Left +AlignOperands: Align +AlignTrailingComments: + Kind: Always + OverEmptyLines: 0 +ColumnLimit: 0 +ReflowComments: false +SortIncludes: Never +SortUsingDeclarations: false +InsertBraces: false +RemoveBracesLLVM: false +FixNamespaceComments: false diff --git a/scripts/waifulib/clang_format.py b/scripts/waifulib/clang_format.py new file mode 100644 index 00000000..395342d3 --- /dev/null +++ b/scripts/waifulib/clang_format.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python +# encoding: utf-8 +# clang_format - `./waf format` runs clang-format -i in parallel on opted-in source files. +# Copyright (C) 2026 Xash3D FWGS contributors +# +# To override clang-format binary: +# CLANG_FORMAT=xxx ./waf configure +# CLANG_FORMAT=xxx ./waf format # for post-configure override, won't check for minimum version +# +# Opt task gens in: +# bld.stlib(features='format', source='...', target='...', ...) + +from waflib import Build, Logs, Task, TaskGen, Utils +import os +import re +import subprocess + +# Enforce minimum major version +CLANG_FORMAT_MIN_MAJOR = None + +# File extensions to format +CLANG_FORMAT_EXTS = ['.c', '.cpp', '.h'] + +def probe_major(binary): + try: + out = subprocess.check_output([binary, '--version'], stderr=subprocess.DEVNULL).decode('utf-8', 'replace') + except (OSError, subprocess.CalledProcessError): + return None + + m = re.search(r'version\s+(\d+)', out) + return int(m.group(1)) if m else None + +def configure(conf): + conf.find_program('clang-format', var='CLANG_FORMAT', mandatory=False) + + if conf.env.CLANG_FORMAT and CLANG_FORMAT_MIN_MAJOR is not None: + major = probe_major(conf.env.CLANG_FORMAT[0]) + + if major is None: + Logs.warn('clang-format is not an executable or invalid') + return + + if major < CLANG_FORMAT_MIN_MAJOR: + conf.fatal('clang-format %d found, need >= %d' % (major, CLANG_FORMAT_MIN_MAJOR)) + + conf.msg('Checking clang-format version', major) + +class ClangFormatTask(Task.Task): + color = 'PINK' + always_run = True + binary = 'clang-format' # just a default, will be overriden + + def keyword(self): + return 'Formatting' + + def run(self): + return self.exec_command([self.binary, '-i', self.inputs[0].abspath()], env=self.env.env or None) + +# need this because if we add headers to to `source` +# we can catch it in `format` feature, as nothing else +# catches .h +@TaskGen.extension('.h') +def format_stub(self, node): + pass + +@TaskGen.feature('format') +def make_format_tasks(self): + if self.bld.cmd != 'format': + return + + for n in self.to_nodes(Utils.to_list(getattr(self, 'source', []))): + if n.suffix() in CLANG_FORMAT_EXTS: + self.create_task('ClangFormatTask', n) + + self.source = [] # remove anything build related + +class FormatContext(Build.BuildContext): + cmd = 'format' + fun = 'build' + + def execute(self): + self.restore() + if not self.all_envs: + self.load_envs() + self.recurse([self.run_dir]) + + binary = os.environ.get('CLANG_FORMAT') or (self.env.CLANG_FORMAT and self.env.CLANG_FORMAT[0]) + if not binary: + self.fatal('format: clang-format not configured') + ClangFormatTask.binary = binary + + # loops below filter taskgens by target name, format feature and by c-f task class + wanted = set(self.targets.split(',')) if self.targets else None + tasks = [] + for group in self.groups: + for tg in group: + if not isinstance(tg, TaskGen.task_gen): + continue + + if 'format' not in Utils.to_list(tg.features): + continue + + if wanted and getattr(tg, 'name', None) not in wanted: + continue + + tg.post() + for t in tg.tasks: + if isinstance(t, ClangFormatTask): + tasks.append(t) + + if not tasks: + self.fatal("format: no sources matched (need features='format' on a task gen)") + + Logs.info('format: %s on %d file(s)' % (binary, len(tasks))) + self.progress_bar = 0 # FIXME: doesn't work with progress bar for some reason + self.groups = [tasks] + self.cur_group = 0 + self.targets = '' + self.compile() diff --git a/utils/xash-clang-format/config.c b/utils/xash-clang-format/config.c new file mode 100644 index 00000000..feeaed0c --- /dev/null +++ b/utils/xash-clang-format/config.c @@ -0,0 +1,91 @@ +/* +config.c - .clang-format extension parser +Copyright (C) 2026 Xash3D FWGS contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU 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 General Public License for more details. +*/ +#include +#include "crtlib.h" +#include "xcf.h" + +#define XCF_KEY "XashCollapseParens" + +/* +============ +ConfigDefault +============ +*/ +static void ConfigDefault( xcf_config_t *c ) +{ + c->collapse_parens = false; +} + +/* +============ +SkipWhitespace +============ +*/ +static const char *SkipWhitespace( const char *p, const char *end ) +{ + for( ; p < end && ( *p == ' ' || *p == '\t' ); p++ ) + ; + return p; +} + +/* +============ +ParseConfigText + +Parse extension keys out of a YAML text body. +We only look at lines that begin (after optional whitespace) with `#`. +============ +*/ +qboolean ParseConfigText( const char *text, xcf_config_t *cfg ) +{ + ConfigDefault( cfg ); + if( !text ) + return true; + + for( const char *p = text; *p; ) + { + const char *end = Q_strchrnul( p, '\n' ); + const char *q; + const size_t klen = sizeof( XCF_KEY ) - 1; + + q = SkipWhitespace( p, end ); + + if( q < end && *q == '#' ) + { + q = SkipWhitespace( q + 1, end ); + + if( end - q >= klen && memcmp( q, XCF_KEY, klen ) == 0 ) + { + q = SkipWhitespace( q + klen, end ); + + if( q < end && *q == ':' ) + { + q = SkipWhitespace( q + 1, end ); + + if( end - q >= 4 && memcmp( q, "true", 4 ) == 0 ) + cfg->collapse_parens = true; + else if( end - q >= 5 && memcmp( q, "false", 5 ) == 0 ) + cfg->collapse_parens = false; + } + } + } + + if( !*end ) + break; + p = end + 1; + } + + return true; +} diff --git a/utils/xash-clang-format/main.c b/utils/xash-clang-format/main.c new file mode 100644 index 00000000..c9f67083 --- /dev/null +++ b/utils/xash-clang-format/main.c @@ -0,0 +1,402 @@ +/* +main.c - xash-clang-format wrapper entry point +Copyright (C) 2026 Xash3D FWGS contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU 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 General Public License for more details. +*/ +#include +#include +#include +#include +#include +#include +#include "crtlib.h" +#include "xcf.h" + +#ifndef XCF_CLANG_FORMAT_DEFAULT +#define XCF_CLANG_FORMAT_DEFAULT "clang-format" +#endif + +// TODO: move to custom config keys +#define XCF_MIN_CLANG_FORMAT_MAJOR 17 + +static const char *g_clang_format = NULL; + +/* +============ +ResolveClangFormat +============ +*/ +static const char *ResolveClangFormat( void ) +{ + const char *e = getenv( "XASH_CLANG_FORMAT_BIN" ); + + if( !COM_StringEmptyOrNULL( e )) + return e; + + return XCF_CLANG_FORMAT_DEFAULT; +} + +/* +============ +IsFileArg + +Attempt to distinguish files from other args. +But to keep parsing args simple, it will parse value as --key value as +filename. :( +============ +*/ +static qboolean IsFileArg( const char *a ) +{ + if( COM_StringEmptyOrNULL( a )) + return false; + if( a[0] == '-' ) + return false; + return true; +} + +/* +============ +ReadTheFile + +============ +*/ +static qboolean ReadTheFile( const char *path, xcf_buf_t *out ) +{ + FILE *f = fopen( path, "rb" ); + + if( !f ) + return false; + + char buf[8192]; + size_t n; + + while(( n = fread( buf, 1, sizeof( buf ), f )) > 0 ) + { + if( !BufPutMem( out, buf, n )) + { + fclose( f ); + return false; + } + } + + fclose( f ); + return true; +} + +/* +============ +WriteTheFile + +============ +*/ +static qboolean WriteTheFile( const char *path, const char *data, size_t len ) +{ + FILE *f = fopen( path, "wb" ); + + if( !f ) + return false; + if( fwrite( data, 1, len, f ) != len ) + { + fclose( f ); + return false; + } + if( fclose( f ) != 0 ) + return false; + return true; +} + +/* +============ +FindConfig +============ +*/ +static char *FindConfig( const char *startdir ) +{ + char dir[MAX_SYSPATH]; + + if( startdir ) + { + Q_strncpy( dir, startdir, sizeof( dir )); + } + else if( !getcwd( dir, sizeof( dir ))) + { + return NULL; + } + + for( ;; ) + { + char path[MAX_SYSPATH]; + struct stat st; + char *slash; + + Q_snprintf( path, sizeof( path ), "%s/.clang-format", dir ); + + if( stat( path, &st ) == 0 && S_ISREG( st.st_mode )) + { + size_t len = Q_strlen( path ) + 1; + char *r = malloc( len ); + + if( r ) + Q_strncpy( r, path, len ); + return r; + } + + slash = Q_strrchr( dir, '/' ); + if( !slash || slash == dir ) + break; + *slash = 0; + } + + return NULL; +} + +/* +============ +LoadConfig +============ +*/ +static qboolean LoadConfig( const char *path, xcf_config_t *cfg ) +{ + xcf_buf_t buf = { 0 }; + qboolean ok = false; + + if( path && ReadTheFile( path, &buf ) && BufPutChar( &buf, 0 )) + ok = ParseConfigText( buf.data, cfg ); + else + ParseConfigText( "", cfg ); + + BufFree( &buf ); + return ok; +} + +/* +============ +Emit + +Transform pass on buffer and output to file +============ +*/ +static qboolean Emit( const xcf_config_t *cfg, const xcf_buf_t *raw, FILE *dst, const char *write_path ) +{ + xcf_buf_t out = { 0 }; + const char *data; + size_t len; + if( cfg->collapse_parens && Transform( raw->data, raw->len, &out )) + { + data = out.data; + len = out.len; + } + else + { + data = raw->data; + len = raw->len; + } + + qboolean ok = true; + if( write_path ) + { + if( !WriteTheFile( write_path, data, len )) + { + fprintf( stderr, "xash-clang-format: cannot write %s: %s\n", write_path, strerror( errno )); + ok = false; + } + } + else if( len ) + { + if( fwrite( data, 1, len, dst ) != len ) + ok = false; + } + + BufFree( &out ); + return ok; +} + +/* +============ +PipeMode + +Process stdout (when clang-format called without -i) +============ +*/ +static int PipeMode( int argc, char **argv, const xcf_config_t *cfg ) +{ + char **fwd = malloc( sizeof( char * ) * ( argc + 1 )); + if( !fwd ) + return 1; + + fwd[0] = (char *)g_clang_format; + for( int i = 1; i < argc; i++ ) + fwd[i] = argv[i]; + fwd[argc] = NULL; + + xcf_buf_t raw = { 0 }; + int rc = RunCapture( fwd, &raw, false ); + free( fwd ); + + if( rc != 0 ) + { + // pass through whatever clang-format produced + if( raw.len ) + fwrite( raw.data, 1, raw.len, stdout ); + BufFree( &raw ); + return rc < 0 ? 1 : rc; + } + + rc = Emit( cfg, &raw, stdout, NULL ) ? 0 : 1; + BufFree( &raw ); + return rc; +} + +/* +============ +PostProcessFile + +Read `path`, run the paren-collapse pass, write result back. +============ +*/ +static qboolean PostProcessFile( const char *path ) +{ + xcf_buf_t in = { 0 }; + xcf_buf_t out = { 0 }; + qboolean ok = false; + + if( ReadTheFile( path, &in ) && Transform( in.data, in.len, &out )) + ok = WriteTheFile( path, out.data, out.len ); + + BufFree( &in ); + BufFree( &out ); + return ok; +} + +/* +============ +InPlaceMode + +Post-process mode, when clang-format is called with -i +============ +*/ +static int InPlaceMode( int argc, char **argv, const xcf_config_t *cfg ) +{ + char **fwd = malloc( sizeof( char * ) * ( argc + 1 )); + + if( !fwd ) + return 1; + + fwd[0] = (char *)g_clang_format; + for( int i = 1; i < argc; i++ ) + fwd[i] = argv[i]; + fwd[argc] = NULL; + + xcf_buf_t sink = { 0 }; + int rc = RunCapture( fwd, &sink, false ); + + BufFree( &sink ); + free( fwd ); + + if( rc != 0 ) + return rc < 0 ? 1 : rc; + + if( !cfg->collapse_parens ) + return 0; + + for( int i = 1; i < argc; i++ ) + { + if( !IsFileArg( argv[i] )) + continue; + if( !PostProcessFile( argv[i] )) + { + fprintf( stderr, "xash-clang-format: post-process failed for %s\n", argv[i] ); + rc = 1; + } + } + return rc; +} + +static void Usage( FILE *dst ) +{ + fprintf( dst, + "xash-clang-format: clang-format wrapper with xash3d-fwgs extensions\n" + "\n" + "Usage: xash-clang-format [clang-format options] [ ...]\n" + " xash-clang-format -i ...\n" + "\n" + "Most clang-format flags are forwarded as-is but there are limitations:\n" + "* --key value must be passed as --key=value\n" + "\n" + "Use XASH_CLANG_FORMAT_BIN envvar to override the clang-format binary;\n" + " setting it also skips the version probe.\n" + "\n" + "Wrapper's config is shared with clang-format config, but extended keys\n" + " start with comment hash.\n" + "New keys:\n" + " XashCollapseParens, accepts 'true' or 'false'\n" ); +} + +int main( int argc, char **argv ) +{ + g_clang_format = ResolveClangFormat(); + + qboolean has_inplace = false; + qboolean has_version = false; + qboolean has_help = false; + + for( int i = 1; i < argc; i++ ) + { + if( Q_strcmp( argv[i], "-i" ) == 0 ) + has_inplace = true; + else if( Q_strcmp( argv[i], "--version" ) == 0 || Q_strcmp( argv[i], "-version" ) == 0 ) + has_version = true; + else if( Q_strcmp( argv[i], "--help" ) == 0 || Q_strcmp( argv[i], "-h" ) == 0 || Q_strcmp( argv[i], "-help" ) == 0 ) + has_help = true; + } + + if( has_help ) + { + Usage( stdout ); + return 0; + } + + if( has_version ) + { + // transparent passthrough so tools that scrape `clang-format --version` + // (e.g. git clang-format auto-detection) keep working + execlp( g_clang_format, g_clang_format, "--version", (char *)NULL ); + fprintf( stderr, "xash-clang-format: cannot exec %s: %s\n", g_clang_format, strerror( errno )); + return 127; + } + + // if caller sets XASH_CLANG_FORMAT_BIN — they own version compatibility + // so skip the probe (lets ./waf format do the check once for parallel invocation) + if( COM_StringEmptyOrNULL( getenv( "XASH_CLANG_FORMAT_BIN" ))) + { + int major = 0; + + if( !ClangFormatVersion( g_clang_format, &major )) + { + fprintf( stderr, "xash-clang-format: cannot probe clang-format version (binary: %s)\n", g_clang_format ); + return 2; + } + if( major < XCF_MIN_CLANG_FORMAT_MAJOR ) + { + fprintf( stderr, "xash-clang-format: clang-format %d found, need >= %d\n", + major, XCF_MIN_CLANG_FORMAT_MAJOR ); + return 2; + } + } + + xcf_config_t cfg; + char *cfgpath = FindConfig( NULL ); + LoadConfig( cfgpath, &cfg ); + free( cfgpath ); + + return has_inplace ? InPlaceMode( argc, argv, &cfg ) : PipeMode( argc, argv, &cfg ); +} diff --git a/utils/xash-clang-format/proc.c b/utils/xash-clang-format/proc.c new file mode 100644 index 00000000..c4c725a0 --- /dev/null +++ b/utils/xash-clang-format/proc.c @@ -0,0 +1,99 @@ +/* +proc.c - subprocess utilities +Copyright (C) 2026 Xash3D FWGS contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU 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 General Public License for more details. +*/ +#include +#include +#include +#include +#include +#include +#include "xcf.h" + +/* +============ +RunCapture + +Spawn argv as a child, capture stdout into `out`. +============ +*/ +int RunCapture( char *const argv[], xcf_buf_t *out, qboolean silent_stderr ) +{ + int p[2]; + pid_t pid; + char buf[8192]; + int status; + + if( pipe( p ) < 0 ) + return -1; + + pid = fork(); + if( pid < 0 ) + { + close( p[0] ); + close( p[1] ); + return -1; + } + + if( pid == 0 ) + { + close( p[0] ); + dup2( p[1], STDOUT_FILENO ); + close( p[1] ); + if( silent_stderr ) + { + int devnull = open( "/dev/null", O_WRONLY ); + + if( devnull >= 0 ) + { + dup2( devnull, STDERR_FILENO ); + close( devnull ); + } + } + execvp( argv[0], argv ); + _exit( 127 ); + } + + close( p[1] ); + + for( ;; ) + { + ssize_t r = read( p[0], buf, sizeof( buf )); + + if( r < 0 ) + { + if( errno == EINTR ) + continue; + break; + } + if( r == 0 ) + break; + if( !BufPutMem( out, buf, r )) + { + close( p[0] ); + kill( pid, SIGKILL ); + waitpid( pid, NULL, 0 ); + return -1; + } + } + close( p[0] ); + + if( waitpid( pid, &status, 0 ) < 0 ) + return -1; + + // heh wife excited + if( WIFEXITED( status )) + return WEXITSTATUS( status ); + + return -1; +} diff --git a/utils/xash-clang-format/tests/test_config.c b/utils/xash-clang-format/tests/test_config.c new file mode 100644 index 00000000..07b33fd1 --- /dev/null +++ b/utils/xash-clang-format/tests/test_config.c @@ -0,0 +1,77 @@ +#include +#include "xcf.h" + +static int run_case( const char *label, const char *yaml, qboolean expect ) +{ + xcf_config_t cfg; + + ParseConfigText( yaml, &cfg ); + + if( cfg.collapse_parens != expect ) + { + fprintf( stderr, "[%s] collapse_parens=%d, expected %d\n yaml:\n%s\n", + label, cfg.collapse_parens, expect, yaml ); + return 0; + } + return 1; +} + +int main( void ) +{ + int n = 0; + + if( !run_case( "empty", "", false )) + return ++n; + + xcf_config_t cfg2; + + ParseConfigText( NULL, &cfg2 ); + if( cfg2.collapse_parens ) + return ++n; + + if( !run_case( "explicit true", + "# XashCollapseParens: true\n", true )) + return ++n; + + if( !run_case( "explicit false", + "# XashCollapseParens: false\n", false )) + return ++n; + + if( !run_case( "leading ws", + " # XashCollapseParens: true\n", true )) + return ++n; + + if( !run_case( "tabs", + "#\tXashCollapseParens\t:\ttrue\n", true )) + return ++n; + + if( !run_case( "unrelated key", + "# SomeOtherKey: true\nIndentWidth: 4\n", false )) + return ++n; + + if( !run_case( "in larger config", + "---\n" + "Language: Cpp\n" + "# XashCollapseParens: true\n" + "IndentWidth: 4\n", + true )) + return ++n; + + if( !run_case( "junk value", + "# XashCollapseParens: maybe\n", false )) + return ++n; + + if( !run_case( "no colon", + "# XashCollapseParens true\n", false )) + return ++n; + + if( !run_case( "last wins", + "# XashCollapseParens: false\n# XashCollapseParens: true\n", true )) + return ++n; + + if( !run_case( "no trailing nl", + "# XashCollapseParens: true", true )) + return ++n; + + return 0; +} diff --git a/utils/xash-clang-format/tests/test_transform.c b/utils/xash-clang-format/tests/test_transform.c new file mode 100644 index 00000000..575beea5 --- /dev/null +++ b/utils/xash-clang-format/tests/test_transform.c @@ -0,0 +1,171 @@ +#include +#include +#include "xcf.h" + +static int run_case( const char *label, const char *in, const char *expect ) +{ + xcf_buf_t out = { 0 }; + int ok; + + if( !Transform( in, strlen( in ), &out )) + { + fprintf( stderr, "[%s] Transform returned error\n", label ); + BufFree( &out ); + return 0; + } + + ok = ( out.len == strlen( expect ) && memcmp( out.data, expect, out.len ) == 0 ); + + if( !ok ) + { + fprintf( stderr, "[%s] mismatch\n in: %s\n out: %.*s\n expect: %s\n", + label, in, (int)out.len, out.data, expect ); + } + + BufFree( &out ); + return ok; +} + +int main( void ) +{ + int n = 0; + + if( !run_case( "outer parens", + "if( ( a + b ) )\n", + "if(( a + b ))\n" )) + return ++n; + + if( !run_case( "triple nesting", + "x = ( ( ( a ) ) );\n", + "x = ((( a )));\n" )) + return ++n; + + if( !run_case( "mixed [ )", + "arr[i] )\n", + "arr[i] )\n" )) + return ++n; + if( !run_case( "mixed ) ]", + "f( a ) ];\n", + "f( a ) ];\n" )) + return ++n; + + if( !run_case( "newline boundary", + "(\n(\n", + "(\n(\n" )) + return ++n; + + if( !run_case( "string literal", + "x = \"( ( hello ) )\";\n", + "x = \"( ( hello ) )\";\n" )) + return ++n; + + if( !run_case( "escaped quote in string", + "x = \"a \\\" ( ( b ) ) \";\n", + "x = \"a \\\" ( ( b ) ) \";\n" )) + return ++n; + + if( !run_case( "line comment", + "a; // ( ( comment ) )\nb;\n", + "a; // ( ( comment ) )\nb;\n" )) + return ++n; + + if( !run_case( "block comment", + "/* ( ( leave\nthis ) ) alone */ x;\n", + "/* ( ( leave\nthis ) ) alone */ x;\n" )) + return ++n; + + if( !run_case( "char literal '('", + "if( c == '(' )\n", + "if( c == '(' )\n" )) + return ++n; + + if( !run_case( "raw string", + "const char *s = R\"x(( (\nstays )x\";\n", + "const char *s = R\"x(( (\nstays )x\";\n" )) + return ++n; + + if( !run_case( "raw string empty delim", + "R\"(( (\n))\"", + "R\"(( (\n))\"" )) + return ++n; + + if( !run_case( "ident Rabbit not raw", + "int Rabbit = ( ( 1 ) );\n", + "int Rabbit = (( 1 ));\n" )) + return ++n; + + if( !run_case( "u8R raw", + "const auto s = u8R\"(( ( )\";\n", + "const auto s = u8R\"(( ( )\";\n" )) + return ++n; + + if( !run_case( "include angle", + "#include \nf( ( x ) );\n", + "#include \nf(( x ));\n" )) + return ++n; + + if( !run_case( "tabs between parens", + "f(\t(\tx\t)\t);\n", + "f((\tx\t));\n" )) + return ++n; + + if( !run_case( "empty", "", "" )) + return ++n; + + if( !run_case( "idempotent", + "f((( x )));\n", + "f((( x )));\n" )) + return ++n; + + if( !run_case( "already touching", + "((a))", + "((a))" )) + return ++n; + + if( !run_case( "single paren", + "f( a, b )", + "f( a, b )" )) + return ++n; + + if( !run_case( "multiple pairs", + "f( ( a ) ) + g( ( b ) );\n", + "f(( a )) + g(( b ));\n" )) + return ++n; + + if( !run_case( "L wide string", + "wchar_t *s = L\"( ( x ) )\";\n", + "wchar_t *s = L\"( ( x ) )\";\n" )) + return ++n; + + if( !run_case( "u utf16 string", + "auto s = u\"( ( x ) )\";\n", + "auto s = u\"( ( x ) )\";\n" )) + return ++n; + + if( !run_case( "U utf32 string", + "auto s = U\"( ( x ) )\";\n", + "auto s = U\"( ( x ) )\";\n" )) + return ++n; + + if( !run_case( "u8 utf8 string", + "auto s = u8\"( ( x ) )\";\n", + "auto s = u8\"( ( x ) )\";\n" )) + return ++n; + + if( !run_case( "L wide char", + "if( c == L'(' )\n", + "if( c == L'(' )\n" )) + return ++n; + + if( !run_case( "u8 char", + "if( c == u8'(' )\n", + "if( c == u8'(' )\n" )) + return ++n; + + if( !run_case( "ident L not prefix", + "int callL = ( ( 1 ) );\n", + "int callL = (( 1 ));\n" )) + return ++n; + + return 0; +} diff --git a/utils/xash-clang-format/tests/test_version.c b/utils/xash-clang-format/tests/test_version.c new file mode 100644 index 00000000..c5a77b53 --- /dev/null +++ b/utils/xash-clang-format/tests/test_version.c @@ -0,0 +1,58 @@ +#include +#include "xcf.h" + +static int run_case( const char *label, const char *s, qboolean expect_ok, int expect_major ) +{ + int major = -1; + qboolean ok = ParseVersion( s, &major ); + + if( ok != expect_ok ) + { + fprintf( stderr, "[%s] ok=%d expected %d (input: %s)\n", label, ok, expect_ok, s ? s : "(null)" ); + return 0; + } + if( ok && major != expect_major ) + { + fprintf( stderr, "[%s] major=%d expected %d (input: %s)\n", label, major, expect_major, s ); + return 0; + } + return 1; +} + +int main( void ) +{ + int n = 0; + + if( !run_case( "vanilla 17", + "clang-format version 17.0.6 (Fedora 17.0.6-1.fc40)\n", true, 17 )) + return ++n; + + if( !run_case( "ubuntu 18", + "Ubuntu clang-format version 18.1.3 (1ubuntu1)\n", true, 18 )) + return ++n; + + if( !run_case( "large major", + "clang-format version 100.0.0\n", true, 100 )) + return ++n; + + if( !run_case( "garbage", + "this has no version here\n", false, 0 )) + return ++n; + + if( !run_case( "null", NULL, false, 0 )) + return ++n; + + if( !run_case( "distro tag with digits", + "Ubuntu99 clang-format version 17.0.6\n", true, 17 )) + return ++n; + + if( !run_case( "bare digits", + "foo 42 bar\n", false, 0 )) + return ++n; + + if( !run_case( "no version word", + "fake clang-format 19.1.0\n", false, 0 )) + return ++n; + + return 0; +} diff --git a/utils/xash-clang-format/transform.c b/utils/xash-clang-format/transform.c new file mode 100644 index 00000000..09fc59f4 --- /dev/null +++ b/utils/xash-clang-format/transform.c @@ -0,0 +1,325 @@ +/* +transform.c - source post-processing pass +Copyright (C) 2026 Xash3D FWGS contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU 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 General Public License for more details. +*/ +#include +#include +#include +#include "xcf.h" + +#define XCF_RAW_DELIM_MAX 16 // C++11: 16 chars max in raw-string delimiter + +/* +============ +BufFree + +============ +*/ +void BufFree( xcf_buf_t *b ) +{ + free( b->data ); + b->data = NULL; + b->len = b->cap = 0; +} + +/* +============ +BufGrow + +============ +*/ +static qboolean BufGrow( xcf_buf_t *b, size_t need ) +{ + size_t cap; + char *p; + + if( b->len + need <= b->cap ) + return true; + + cap = b->cap ? b->cap : 256; + while( cap < b->len + need ) + cap *= 2; + + p = realloc( b->data, cap ); + if( !p ) + return false; + + b->data = p; + b->cap = cap; + return true; +} + +/* +============ +BufPutChar + +============ +*/ +qboolean BufPutChar( xcf_buf_t *b, char c ) +{ + if( !BufGrow( b, 1 )) + return false; + b->data[b->len++] = c; + return true; +} + +/* +============ +BufPutMem + +============ +*/ +qboolean BufPutMem( xcf_buf_t *b, const char *src, size_t n ) +{ + if( !n ) + return true; + if( !BufGrow( b, n )) + return false; + memcpy( b->data + b->len, src, n ); + b->len += n; + return true; +} + +/* +============ +TryCollapse + +Collapse spaces between the same type parentheses +============ +*/ +static void TryCollapse( xcf_buf_t *out, char paren ) +{ + size_t i = out->len; + + while( i > 0 && ( out->data[i - 1] == ' ' || out->data[i - 1] == '\t' )) + i--; + + if( i == 0 || i == out->len ) + return; // nothing to collapse, or no whitespace between + + if( out->data[i - 1] != paren ) + return; // different bracket types or different char entirely + + out->len = i; +} + +/* +============ +DetectRaw + +Detect C++11 raw string because why not +============ +*/ +static qboolean DetectRaw( const char *s, size_t len, size_t i, size_t *prefix_end, size_t *paren ) +{ + size_t j = i, k; + + // must not be in the middle of an identifier + if( i > 0 && ( isalnum( s[i - 1] ) || s[i - 1] == '_' )) + return false; + + if( j + 2 < len && s[j] == 'u' && s[j + 1] == '8' && s[j + 2] == 'R' ) + j += 3; + else if( j + 1 < len && ( s[j] == 'u' || s[j] == 'U' || s[j] == 'L' ) && s[j + 1] == 'R' ) + j += 2; + else if( j < len && s[j] == 'R' ) + j += 1; + else + return false; + + if( j >= len || s[j] != '"' ) + return false; + j++; // past the opening quote + *prefix_end = j; + + k = j; + while( k < len && s[k] != '(' && k - j < XCF_RAW_DELIM_MAX ) + k++; + + if( k >= len || s[k] != '(' ) + return false; + + *paren = k; + return true; +} + +enum +{ + S_NORMAL, + S_LINE_CMT, + S_BLOCK_CMT, + S_STRING, + S_CHAR, + S_RAW, +}; + +/* +============ +Transform + +Parse source, find parens and try collapse them +============ +*/ +qboolean Transform( const char *in, size_t in_len, xcf_buf_t *out ) +{ + size_t raw_dlen = 0; + char raw_delim[XCF_RAW_DELIM_MAX + 1]; + int state = S_NORMAL; + + for( size_t i = 0; i < in_len; i++ ) + { + char c1 = in[i]; + char c2 = ( i + 1 < in_len ) ? in[i + 1] : 0; + + switch( state ) + { + case S_NORMAL: + { + size_t pe, pp; + + if( c1 == '/' && c2 == '/' ) + { + if( !BufPutMem( out, in + i, 2 )) + return false; + i++; + state = S_LINE_CMT; + break; + } + + if( c1 == '/' && c2 == '*' ) + { + if( !BufPutMem( out, in + i, 2 )) + return false; + i++; + state = S_BLOCK_CMT; + break; + } + + if( DetectRaw( in, in_len, i, &pe, &pp )) + { + size_t dl = pp - pe; + + if( dl > XCF_RAW_DELIM_MAX ) + dl = XCF_RAW_DELIM_MAX; + + if( !BufPutMem( out, in + i, pp - i + 1 )) + return false; + memcpy( raw_delim, in + pe, dl ); + raw_delim[dl] = 0; + raw_dlen = dl; + i = pp; // sit on the '(' + state = S_RAW; + break; + } + + if( c1 == '"' ) + { + if( !BufPutChar( out, c1 )) + return false; + state = S_STRING; + break; + } + + if( c1 == '\'' ) + { + if( !BufPutChar( out, c1 )) + return false; + state = S_CHAR; + break; + } + + if( c1 == '(' || c1 == ')' ) + TryCollapse( out, c1 ); + + if( !BufPutChar( out, c1 )) + return false; + break; + } + + case S_LINE_CMT: + if( !BufPutChar( out, c1 )) + return false; + if( c1 == '\n' ) + state = S_NORMAL; + break; + + case S_BLOCK_CMT: + if( !BufPutChar( out, c1 )) + return false; + if( c1 == '*' && c2 == '/' ) + { + if( !BufPutChar( out, c2 )) + return false; + i++; + state = S_NORMAL; + } + break; + + case S_STRING: + if( !BufPutChar( out, c1 )) + return false; + if( c1 == '\\' && c2 ) + { + if( !BufPutChar( out, c2 )) + return false; + i++; + } + else if( c1 == '"' ) + { + state = S_NORMAL; + } + else if( c1 == '\n' ) + { + // unterminated; recover at next line + state = S_NORMAL; + } + break; + + case S_CHAR: + if( !BufPutChar( out, c1 )) + return false; + if( c1 == '\\' && c2 ) + { + if( !BufPutChar( out, c2 )) + return false; + i++; + } + else if( c1 == '\'' ) + { + state = S_NORMAL; + } + else if( c1 == '\n' ) + { + state = S_NORMAL; + } + break; + + case S_RAW: + if( !BufPutChar( out, c1 )) + return false; + if( c1 == ')' + && i + 1 + raw_dlen + 1 <= in_len + && memcmp( in + i + 1, raw_delim, raw_dlen ) == 0 + && in[i + 1 + raw_dlen] == '"' ) + { + if( !BufPutMem( out, in + i + 1, raw_dlen + 1 )) + return false; + i += raw_dlen + 1; + state = S_NORMAL; + } + break; + } + } + + return true; +} diff --git a/utils/xash-clang-format/version.c b/utils/xash-clang-format/version.c new file mode 100644 index 00000000..d33a083f --- /dev/null +++ b/utils/xash-clang-format/version.c @@ -0,0 +1,67 @@ +/* +version.c - clang-format version probe +Copyright (C) 2026 Xash3D FWGS contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU 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 General Public License for more details. +*/ +#include "crtlib.h" +#include "xcf.h" + +/* +============ +ParseVersion + +Parse the major version number out of a `clang-format --version` +============ +*/ +qboolean ParseVersion( const char *s, int *major ) +{ + const char *p; + + if( COM_StringEmptyOrNULL( s ) || !major ) + return false; + + // anchor on "version " + p = Q_strstr( s, "version " ); + if( !p ) + return false; + p += sizeof( "version " ) - 1; + + while( *p == ' ' || *p == '\t' ) + p++; + if( *p < '0' || *p > '9' ) + return false; + + *major = Q_atoi( p ); + return true; +} + +/* +============ +ClangFormatVersion + +============ +*/ +qboolean ClangFormatVersion( const char *exe, int *major ) +{ + char *argv[] = { (char *)exe, (char *)"--version", NULL }; + xcf_buf_t out = { 0 }; + qboolean ok = false; + + if( !exe || !major ) + return false; + + if( RunCapture( argv, &out, true ) == 0 && BufPutChar( &out, 0 )) + ok = ParseVersion( out.data, major ); + + BufFree( &out ); + return ok; +} diff --git a/utils/xash-clang-format/wscript b/utils/xash-clang-format/wscript new file mode 100644 index 00000000..b060f025 --- /dev/null +++ b/utils/xash-clang-format/wscript @@ -0,0 +1,42 @@ +#! /usr/bin/env python +# encoding: utf-8 + +def options(opt): + grp = opt.get_option_group('Utilities options') + + grp.add_option('--disable-utils-xcf', action='store_true', + dest='DISABLE_UTILS_XCF', default = False, + help='disable xash-clang-format wrapper utility [default: %(default)s]') + +def configure(conf): + # wrapper relies on POSIX, disable on Windows for now + conf.env.DISABLE_UTILS_XCF = conf.options.DISABLE_UTILS_XCF or conf.env.DEST_OS == 'win32' + +def build(bld): + if bld.env.DISABLE_UTILS_XCF: + return + + bld.stlib(source = bld.path.ant_glob(['*.c', '*.h'], excl='main.c'), + target = 'xcflib', + features = 'format', + includes = '.', + use = 'public werror', + install_path = None) + + bld.program(source = 'main.c', + target = 'xash-clang-format', + features = 'format', + use = 'xcflib werror', + install_path = bld.env.BINDIR, + subsystem = bld.env.CONSOLE_SUBSYSTEM) + + if bld.env.TESTS: + tests = ['transform', 'config', 'version'] + + for name in tests: + bld.program(features = 'test format', + source = 'tests/test_%s.c' % name, + target = 'test_xcflib_%s' % name, + includes = '.', + use = 'xcflib werror', + install_path = None) diff --git a/utils/xash-clang-format/xcf.h b/utils/xash-clang-format/xcf.h new file mode 100644 index 00000000..2128a3dd --- /dev/null +++ b/utils/xash-clang-format/xcf.h @@ -0,0 +1,57 @@ +/* +xcf.h - clang-format wrapper internals +Copyright (C) 2026 Xash3D FWGS contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU 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 General Public License for more details. +*/ +#ifndef XCF_H +#define XCF_H + +#include +#include "xash3d_types.h" + +// +// transform.c +// +typedef struct +{ + char *data; + size_t len; + size_t cap; +} xcf_buf_t; + +void BufFree( xcf_buf_t *b ); +qboolean BufPutChar( xcf_buf_t *b, char c ); +qboolean BufPutMem( xcf_buf_t *b, const char *src, size_t n ); +qboolean Transform( const char *in, size_t in_len, xcf_buf_t *out ); + +// +// config.c +// +typedef struct +{ + qboolean collapse_parens; // default: false +} xcf_config_t; + +qboolean ParseConfigText( const char *text, xcf_config_t *cfg ); + +// +// version.c +// +qboolean ParseVersion( const char *s, int *major ); +qboolean ClangFormatVersion( const char *exe, int *major ); + +// +// proc.c +// +int RunCapture( char *const argv[], xcf_buf_t *out, qboolean silent_stderr ); + +#endif // XCF_H diff --git a/wscript b/wscript index eefd8121..552e2c75 100644 --- a/wscript +++ b/wscript @@ -4,9 +4,13 @@ from waflib import Build, Context, Logs, TaskGen from waflib.Tools import waf_unit_test, c_tests +import clang_format import sys import os +# we need version 17 minimum +clang_format.CLANG_FORMAT_MIN_MAJOR = 17 + VERSION = '0.99' APPNAME = 'xash3d-fwgs' top = '.' @@ -32,6 +36,24 @@ def apply_subsystem_msvc(self): if 'test' in self.features: self.subsystem = self.env.CONSOLE_SUBSYSTEM +@TaskGen.feature('format') +@TaskGen.after_method('make_format_tasks') +def use_xash_clang_format(self): + if self.bld.cmd != 'format': + return + + wrapper = self.bld.bldnode.find_node('utils/xash-clang-format/xash-clang-format') + if not wrapper: + self.bld.fatal('no xash-clang-format found in build directory, reconfigure and rebuild with --enable-utils') + return + + real_cf = clang_format.ClangFormatTask.binary + for t in self.tasks: + if isinstance(t, clang_format.ClangFormatTask): + t.binary = wrapper.abspath() + t.env.env = dict(os.environ, XASH_CLANG_FORMAT_BIN=real_cf) + + class Subproject: def __init__(self, name, fnFilter = None): self.name = name @@ -113,8 +135,9 @@ SUBDIRS = [ Subproject('engine'), # keep latest for static linking # enabled optionally - Subproject('utils/mdldec', lambda x: x.env.ENABLE_UTILS), - Subproject('utils/xar', lambda x: x.env.ENABLE_UTILS and x.env.ENABLE_XAR), + Subproject('utils/mdldec', lambda x: x.env.ENABLE_UTILS), + Subproject('utils/xar', lambda x: x.env.ENABLE_UTILS and x.env.ENABLE_XAR), + Subproject('utils/xash-clang-format', lambda x: x.env.ENABLE_UTILS), # enabled on PSVita only Subproject('ref/gl/vgl_shim', lambda x: x.env.DEST_OS == 'psvita'), @@ -236,7 +259,7 @@ def configure(conf): if conf.env.COMPILER_CC == 'msvc': conf.load('msvc_pdb') - conf.load('msvs subproject clang_compilation_database strip_on_install waf_unit_test enforce_pic force_32bit ninja') + conf.load('msvs subproject clang_compilation_database strip_on_install waf_unit_test enforce_pic force_32bit ninja clang_format') conf.env.MSVC_SUBSYSTEM = 'WINDOWS' conf.env.CONSOLE_SUBSYSTEM = 'CONSOLE'