diff --git a/lint_batch.py b/lint_batch.py new file mode 100755 index 00000000..9a248fe4 --- /dev/null +++ b/lint_batch.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""Simple batch file linter. + +Performs basic static analysis specific to Windows CMD batch syntax. In addition +to unsafe ``echo`` patterns, the linter checks for the following problems: + +* unmatched parentheses and percent expansions +* unclosed quotation marks +* improper ``for`` loop structure +* suspicious ``if`` syntax +* missing ``=`` on ``set`` commands +* references to nonexistent ``goto``/``call`` labels +""" +import re +import sys +from pathlib import Path + + +def lint(path: Path) -> int: + lines = path.read_text(errors="ignore").splitlines() + errors: list[tuple[int, str]] = [] + stack: list[int] = [] + labels: dict[str, int] = {} + refs: list[tuple[int, str]] = [] + + for lineno, raw in enumerate(lines, 1): + line = raw + stripped = line.lstrip() + if stripped.startswith("REM") or stripped.startswith("::"): + continue + + # label definition + m = re.match(r":([A-Za-z0-9_.$?-]+)\b(.*)", stripped) + if m: + name = m.group(1).lower() + if name in labels: + errors.append((lineno, f"duplicate label :{name} (first at {labels[name]})")) + else: + labels[name] = lineno + rest = m.group(2).lstrip() + if not rest: + continue + line = rest + stripped = line.lstrip() + + # check unsafe echo usage anywhere in the line + low = line.lower() + if "echo(" not in low: + m = re.search(r"(?i)(?:^|[\s>&|])echo[ .]", line) + if m: + after = line[m.end():] + if "%" in after: + errors.append((lineno, "use echo( for safe output")) + + # unescape caret escapes + res = "" + esc = False + for ch in line: + if esc: + esc = False + elif ch == "^": + esc = True + continue + res += ch + line_u = res + + # remove quoted segments and track unmatched quotes + in_q = False + prev = "" + tmp = "" + for ch in line_u: + if ch == '"' and prev != "\\": + in_q = not in_q + elif not in_q: + tmp += ch + prev = ch + if in_q: + errors.append((lineno, 'unmatched "')) + + # check for/goto/if/set patterns within unquoted text + tmp_low = tmp.lower() + + if tmp_low.startswith('for'): + if '%%' not in tmp: + errors.append((lineno, 'for loop variable must use %%')) + if ' in ' not in tmp_low or ' do' not in tmp_low: + errors.append((lineno, 'for loop missing IN or DO')) + + if tmp_low.startswith('if'): + if not any(op in tmp_low for op in ['==', ' equ ', ' neq ', ' lss ', + ' gtr ', ' leq ', ' geq ', + ' exist ', ' defined ', + ' errorlevel ']): + errors.append((lineno, 'suspicious if syntax')) + + if line_u.lstrip().lower().startswith('set ') and not line_u.lstrip().lower().startswith('setlocal'): + low_u = line_u.lower() + if '=' not in line_u and '/p' not in low_u and '/a' not in low_u: + errors.append((lineno, 'set without =')) + + mg = re.search(r"(?i)\bgoto\s+(:?\w+)", tmp) + if mg: + lbl = mg.group(1) + if lbl.startswith(':'): + lbl = lbl[1:] + refs.append((lineno, lbl.lower())) + + mc = re.search(r"(?i)\bcall\s+(:[A-Za-z0-9_.$?-]+)", tmp) + if mc: + refs.append((lineno, mc.group(1)[1:].lower())) + + # core syntax checks: parentheses and percent expansions + j = 0 + while j < len(tmp): + ch = tmp[j] + if ch == '(': + seg = tmp[max(0, j - 5):j].lower() + if not seg.endswith('echo'): + stack.append(lineno) + elif ch == ')': + if stack: + stack.pop() + else: + errors.append((lineno, 'unmatched )')) + elif ch == '%': + if j + 1 < len(tmp) and tmp[j + 1] == '%': + j += 2 + continue + if j + 1 < len(tmp) and tmp[j + 1] in '*0123456789': + j += 2 + continue + if j + 1 < len(tmp) and tmp[j + 1] == '~': + k = j + 2 + while k < len(tmp) and tmp[k].isalpha(): + k += 1 + if k < len(tmp) and tmp[k].isdigit(): + j = k + 1 + continue + k = tmp.find('%', j + 1) + if k == -1: + errors.append((lineno, 'unmatched %')) + break + j = k + 1 + continue + j += 1 + + for ln in stack: + errors.append((ln, 'unmatched (')) + + for ln, label in refs: + if label not in labels and label.lower() != 'eof': + errors.append((ln, f'unknown label :{label}')) + + for ln, msg in errors: + print(f"{path}:{ln}: {msg}") + return 1 if errors else 0 + + +def main(argv): + if len(argv) != 2: + print("Usage: lint_batch.py ") + return 1 + return lint(Path(argv[1])) + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/run_setup.bat b/run_setup.bat index e183cc6f..2fc9723b 100644 --- a/run_setup.bat +++ b/run_setup.bat @@ -116,11 +116,11 @@ set "REBUILD_OCCURRED=0" REM Parse key=value args quickly :__parse_args if "%~1"=="" goto :__parse_done -echo.%~1| findstr "=" >nul && set "%~1" +echo(%~1| findstr "=" >nul && set "%~1" shift goto :__parse_args :__parse_done -echo %* | findstr /I "VERBOSE=1" >nul && set "VERBOSE=1" +echo(%*| findstr /I "VERBOSE=1" >nul && set "VERBOSE=1" REM Robust arg parsing for known keys (no delayed expansion) :__parse_known if "%~1"=="" goto :__parse_known_done @@ -243,7 +243,8 @@ if exist "runtime.txt" ( call :log INFO "runtime.txt specifies Python %PY_VER%" ) else ( if exist "pyproject.toml" ( - %PSH% "$raw=Get-Content 'pyproject.toml' -Raw; $m=[regex]::Match($raw,'(?im)^\s*requires-python\s*=\s*\"?([^\"''\r\n]+)'); if($m.Success){$m.Groups[1].Value}" > "~pyreq_spec.txt" +# PowerShell extracts requires-python floor; escape quotes carefully +%PSH% "$raw=Get-Content 'pyproject.toml' -Raw; $m=[regex]::Match($raw,'(?im)^\s*requires-python\s*=\s*\"?([^\"''\r\n]+)'); if($m.Success){$m.Groups[1].Value}" > "~pyreq_spec.txt" for /f "usebackq delims=" %%s in ("~pyreq_spec.txt") do set "PY_SPEC=%%s" del /q "~pyreq_spec.txt" 2>nul if not "%PY_SPEC%"=="" ( @@ -306,7 +307,7 @@ if "%JUST_INSTALLED%"=="1" ( call :log INFO "Updating conda base conda forge channel" call "%CONDABAT%" update -n base -c conda-forge --override-channels conda -y >>"%LOG_FILE%" 2>&1 for /f "usebackq delims=" %%d in (`%PSH% "(Get-Date).ToString('yyyyMMdd')"`) do set "TODAY=%%d" - > "~conda_update.timestamp" echo %TODAY% +> "~conda_update.timestamp" echo(%TODAY% ) else ( call :log INFO "Conda base update not needed age less than 30 days" ) @@ -407,7 +408,7 @@ call "%CONDABAT%" list --prefix "%ENV_PREFIX%" --json > conda_env.json 2>>"%LOG_ if not exist "runtime.txt" ( for /f "delims=" %%v in ('"%PY_EXE%" -c "import sys;print(f\"python-{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}\")"') do set "RES_PY=%%v" if not "%RES_PY%"=="" ( - > "runtime.txt" echo %RES_PY% +> "runtime.txt" echo(%RES_PY% call :log INFO "Recorded %RES_PY% to runtime.txt" ) ) @@ -461,7 +462,7 @@ if "%RC%"=="0" ( for /f "usebackq tokens=5 delims=' " %%m in ("~missing.mod") do set "MISS=%%m" if not "%MISS%"=="" ( call :log INFO "Adding missing package %MISS% to requirements.txt and merging auto list" - echo %MISS%>>requirements.txt + echo(%MISS%>>requirements.txt if exist ~requirements.auto.txt type ~requirements.auto.txt>>requirements.txt ) call :log INFO "Recreating env and reinstalling due to missing package" @@ -542,11 +543,11 @@ REM Fast ASCII logger. Writes all lines to file. Prints DEBUG to console only wh set "LVL=%~1" set "MSG=%~2" set "TS=%date% %time%" ->>"%LOG_FILE%" echo [%TS%] [%LVL%] %MSG% +>>"%LOG_FILE%" echo([%TS%] [%LVL%] %MSG% if /I "%LVL%"=="DEBUG" ( - if "%VERBOSE%"=="1" echo [%TS%] [%LVL%] %MSG% + if "%VERBOSE%"=="1" echo([%TS%] [%LVL%] %MSG% ) else ( - echo [%TS%] [%LVL%] %MSG% + echo([%TS%] [%LVL%] %MSG% ) exit /b 0