Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions lint_batch.py
Original file line number Diff line number Diff line change
@@ -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 <file.bat>")
return 1
return lint(Path(argv[1]))


if __name__ == "__main__":
sys.exit(main(sys.argv))
19 changes: 10 additions & 9 deletions run_setup.bat
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Replace shell-incompatible comment

The newly added comment in the Python version detection section starts with #, but # is not a comment prefix in Windows batch files. When pyproject.toml is present this line executes as a command, printing '#' is not recognized as an internal or external command and setting ERRORLEVEL to 9009 before the PowerShell call. That spurious failure can cause later logic that checks errorlevel to misbehave. Use REM or :: for comments instead.

Useful? React with 👍 / 👎.

%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%"=="" (
Expand Down Expand Up @@ -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"
)
Expand Down Expand Up @@ -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"
)
)
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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

Expand Down