549 lines
24 KiB
Python
549 lines
24 KiB
Python
"""MATLAB-vs-Octave compatibility checking.
|
|
|
|
We execute in GNU Octave and the user runs the result in real MATLAB, so
|
|
there are three failure classes and only one of them is the model's fault:
|
|
|
|
1. Octave ACCEPTS it, MATLAB REJECTS it. The worst case - we would tell
|
|
the user "it works" and it would not.
|
|
2. MATLAB has it, this sandbox does not (toolbox functions, display,
|
|
network). The model must be told NOT to rewrite correct code.
|
|
3. Both run, results differ. Rarely detectable statically; we flag the
|
|
few constructs worth flagging and are honest about the rest.
|
|
|
|
Every rule runs against source with string BODIES and comment BODIES
|
|
blanked out, markers kept. An adversarial review of the first version
|
|
found three high-severity bugs in this file alone, all of them in the
|
|
tokeniser rather than in the rules, so read the notes on strip_literals
|
|
before touching it.
|
|
|
|
The one exception is a rule marked `raw`, which sees the source
|
|
unstripped. That exists because the worst trap we know of lives *inside*
|
|
a string body: `regexp(s, '\b40\b', 'match')` returns nothing at all, so
|
|
a stripped-source rule could never see it and the model gets a wrong
|
|
count instead of an error.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
|
|
# After one of these keywords, a quote opens a string - it cannot be a
|
|
# transpose, because there is no value to transpose. Without this,
|
|
# `case '--'` mis-tokenised and the `--` inside the string was reported
|
|
# as an Octave-only increment operator in perfectly good MATLAB.
|
|
_KEYWORDS_BEFORE_STRING = frozenset({
|
|
"case", "otherwise", "return", "if", "elseif", "else", "while", "switch",
|
|
"for", "parfor", "do", "until", "function", "global", "persistent", "try",
|
|
"catch", "end", "break", "continue", "and", "or", "not", "spmd",
|
|
"classdef", "properties", "methods", "events", "arguments", "disp", "error",
|
|
"warning", "fprintf", "sprintf", "printf", "assert", "validateattributes",
|
|
})
|
|
|
|
_TRANSPOSE_AFTER = frozenset(")]}._'")
|
|
|
|
|
|
def strip_literals(code: str) -> str:
|
|
"""Blank string and comment BODIES, keeping their markers and length.
|
|
|
|
Offsets and line breaks are preserved so a match position still maps
|
|
to the right line of the original source.
|
|
|
|
Three things here are load-bearing, each because getting them wrong
|
|
produced a real, verified defect:
|
|
|
|
* **The comment marker survives.** Blanking from the `%`/`#` itself
|
|
made the `hash-comment` and `block-comment-hash` rules unable to
|
|
match anything, ever - two declared high-severity rules were dead
|
|
code, and `#` comments are the most common Octave-ism there is.
|
|
|
|
* **State resets at a newline, but not across a `...` continuation.**
|
|
Carrying the previous line's last character over meant a quote at
|
|
the start of a line was read as a transpose, so the string body
|
|
leaked into the stripped text and every rule scanned English prose.
|
|
That produced false "NOT VALID MATLAB" verdicts on ordinary code
|
|
like `plot(x, y, ...` / `'--r', 'LineWidth', 2);`. A continuation
|
|
is the one case where state must carry, because the logical line
|
|
genuinely continues.
|
|
|
|
* **`%{ ... %}` blocks are handled as blocks.** Treating the opener
|
|
as a line comment left the prose body to be linted as code.
|
|
"""
|
|
out = list(code)
|
|
n = len(code)
|
|
i = 0
|
|
prev_char = "" # last significant char on this logical line
|
|
word = "" # identifier immediately before the cursor
|
|
|
|
def blank(a: int, b: int) -> None:
|
|
for k in range(a, min(b, n)):
|
|
if out[k] != "\n":
|
|
out[k] = " "
|
|
|
|
def line_end(pos: int) -> int:
|
|
j = code.find("\n", pos)
|
|
return n if j == -1 else j
|
|
|
|
while i < n:
|
|
c = code[i]
|
|
|
|
if c == "\n":
|
|
prev_char = ""
|
|
word = ""
|
|
i += 1
|
|
continue
|
|
|
|
# Line continuation. Everything after `...` is a comment, and the
|
|
# logical line continues - so prev_char/word deliberately persist.
|
|
if c == "." and code.startswith("...", i):
|
|
j = line_end(i)
|
|
blank(i, j)
|
|
i = j + 1 if j < n else n
|
|
continue
|
|
|
|
if c in "%#":
|
|
marker = code[i:i + 2]
|
|
stripped_line = code[code.rfind("\n", 0, i) + 1:line_end(i)].strip()
|
|
# A block comment opener is the only thing on its line.
|
|
if stripped_line in ("%{", "#{"):
|
|
closer = "%}" if marker[0] == "%" else "#}"
|
|
j = i + 2
|
|
while j < n:
|
|
ls = j
|
|
le = line_end(j)
|
|
if code[ls:le].strip() in ("%}", "#}"):
|
|
blank(i + 2, ls) # body only
|
|
i = le
|
|
break
|
|
j = le + 1
|
|
else:
|
|
blank(i + 2, n)
|
|
i = n
|
|
continue
|
|
keep = 2 if (i + 1 < n and code[i + 1] in "{}") else 1
|
|
j = line_end(i)
|
|
blank(i + keep, j)
|
|
i = j # let the newline branch reset state
|
|
continue
|
|
|
|
if c == '"':
|
|
j = i + 1
|
|
while j < n and code[j] != "\n":
|
|
if code[j] == '"':
|
|
if j + 1 < n and code[j + 1] == '"':
|
|
j += 2
|
|
continue
|
|
break
|
|
j += 1
|
|
blank(i + 1, j)
|
|
i = min(j + 1, n)
|
|
prev_char = '"'
|
|
word = ""
|
|
continue
|
|
|
|
if c == "'":
|
|
is_transpose = (
|
|
(prev_char.isalnum() or prev_char in _TRANSPOSE_AFTER)
|
|
and word not in _KEYWORDS_BEFORE_STRING
|
|
)
|
|
if is_transpose:
|
|
prev_char = "'"
|
|
word = ""
|
|
i += 1
|
|
continue
|
|
j = i + 1
|
|
while j < n and code[j] != "\n":
|
|
if code[j] == "'":
|
|
if j + 1 < n and code[j + 1] == "'":
|
|
j += 2
|
|
continue
|
|
break
|
|
j += 1
|
|
blank(i + 1, j)
|
|
i = min(j + 1, n)
|
|
prev_char = "'"
|
|
word = ""
|
|
continue
|
|
|
|
if c.isalnum() or c == "_":
|
|
word += c
|
|
prev_char = c
|
|
elif c.isspace():
|
|
pass # whitespace keeps `word` (for `case 'x'`)
|
|
else:
|
|
word = ""
|
|
prev_char = c
|
|
i += 1
|
|
|
|
return "".join(out)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Rule:
|
|
id: str
|
|
pattern: re.Pattern
|
|
severity: str # "high" | "medium" | "low"
|
|
kind: str # "octave_only" | "sandbox" | "semantic"
|
|
message: str
|
|
# When set, a finding is dropped if the captured name is assigned
|
|
# anywhere in the script - a user variable called `input` or `stderr`
|
|
# is not a call to the builtin, and firing on it teaches the model
|
|
# that this whole section cries wolf.
|
|
name_group: int = 0
|
|
# Match against the ORIGINAL source instead of the literal-stripped
|
|
# copy. Only for rules whose whole subject is the content of a string
|
|
# - a regexp pattern - which is blank by the time the other rules
|
|
# run. The cost is that comments are visible too, so a raw rule can
|
|
# fire on a line of prose; keep raw rules narrow enough that this is
|
|
# rare and harmless.
|
|
raw: bool = False
|
|
|
|
|
|
def _r(pat: str) -> re.Pattern:
|
|
return re.compile(pat, re.MULTILINE)
|
|
|
|
|
|
OCTAVE_ONLY: tuple[Rule, ...] = (
|
|
Rule("hash-comment", _r(r"(?:^|[ \t;,])#(?![{}])"), "high", "octave_only",
|
|
"`#` starts a comment in Octave and is a syntax error in MATLAB. Use `%`."),
|
|
Rule("block-comment-hash", _r(r"^[ \t]*#[{}][ \t]*$"), "high", "octave_only",
|
|
"`#{ ... #}` block comments are Octave-only. MATLAB uses `%{ ... %}`."),
|
|
Rule("bang-ne", _r(r"!="), "high", "octave_only",
|
|
"`!=` is Octave-only. MATLAB spells not-equal `~=`."),
|
|
# NOT anchored at statement start: `!cmd` there is MATLAB's own shell
|
|
# escape and is valid MATLAB, so flagging it as Octave-only would be
|
|
# wrong. shell-escape covers that case separately.
|
|
Rule("bang-not", _r(r"(?<![=~<>!\w])(?<!^)![ \t]*[A-Za-z_(\[\d~]"), "high", "octave_only",
|
|
"`!` as logical NOT is Octave-only. MATLAB uses `~`."),
|
|
Rule("end-keywords", _r(r"(?<![.\w])end(if|while|for|function|switch|_try_catch|parfor)\b"),
|
|
"high", "octave_only",
|
|
"`endif`/`endwhile`/`endfor`/`endfunction`/`endswitch` are Octave-only. MATLAB closes "
|
|
"every block with a bare `end`."),
|
|
Rule("unwind-protect", _r(r"(?<![.\w])unwind_protect\b"), "high", "octave_only",
|
|
"`unwind_protect` is Octave-only. MATLAB has no direct equivalent; use try/catch, or "
|
|
"`onCleanup` for guaranteed cleanup."),
|
|
Rule("do-until", _r(r"^[ \t]*do[ \t]*$|^[ \t]*until[ \t]*\(|^[ \t]*until[ \t]+(?![=\s])"),
|
|
"high", "octave_only",
|
|
"`do ... until` is Octave-only. MATLAB has no do-while; use "
|
|
"`while true ... if cond, break; end, end`."),
|
|
Rule("aug-assign", _r(r"[A-Za-z_0-9)\]}][ \t]*(\+=|-=|\*=|/=|\^=)"), "high", "octave_only",
|
|
"`+=`, `-=`, `*=`, `/=` are Octave-only. MATLAB needs the full form, e.g. `x = x + 1`."),
|
|
Rule("incr-decr", _r(r"(?<![+\-])(\+\+|--)(?![+\-])"), "high", "octave_only",
|
|
"`++` and `--` are Octave-only. MATLAB has no increment operator; write `x = x + 1`."),
|
|
Rule("power-star-star", _r(r"\*\*"), "high", "octave_only",
|
|
"`**` is Octave-only. MATLAB's power operators are `^` and `.^`."),
|
|
Rule("printf", _r(r"(?<![.\w])printf[ \t]*\("), "high", "octave_only",
|
|
"`printf` is Octave-only. MATLAB uses `fprintf`.", 0),
|
|
Rule("octave-io", _r(r"(?<![.\w])(puts|fputs|fdisp|fflush)[ \t]*\("), "high", "octave_only",
|
|
"`puts`/`fputs`/`fdisp`/`fflush` are Octave-only. MATLAB uses `fprintf` and `disp`.", 1),
|
|
Rule("octave-streams", _r(r"(?<![.\w'\"])(stdout|stderr)(?![\w'\"])"), "medium", "octave_only",
|
|
"`stdout`/`stderr` are Octave-only names. MATLAB uses the file identifiers 1 and 2, "
|
|
"e.g. `fprintf(2, ...)` to write to stderr.", 1),
|
|
# `)(` with no space between: `f(x)(2)`. Requiring no space keeps this
|
|
# off `[f(1) (2)]`, which is a two-element matrix, not chained indexing.
|
|
Rule("chained-index", _r(r"\)\("), "medium", "octave_only",
|
|
"Indexing a call's result directly, `f(x)(2)`, is Octave-only. MATLAB needs a "
|
|
"temporary: `t = f(x); t(2)`."),
|
|
)
|
|
|
|
SANDBOX: tuple[Rule, ...] = (
|
|
Rule("interactive-input",
|
|
_r(r"(?<![.\w])(input|keyboard|menu|inputdlg|questdlg|uiwait|uigetfile|ginput)[ \t]*\("),
|
|
"high", "sandbox",
|
|
"This waits for a human. The sandbox runs non-interactively, so it will hang until the "
|
|
"timeout. Hard-code the value or pass it as a variable.", 1),
|
|
Rule("bare-pause", _r(r"(?<![.\w])pause[ \t]*(\(\s*\))?[ \t]*(;|$)"), "medium", "sandbox",
|
|
"`pause` with no argument waits for a keypress and will hang the run. Give it a "
|
|
"duration or remove it."),
|
|
Rule("network",
|
|
_r(r"(?<![.\w])(webread|webwrite|websave|urlread|urlwrite|ftp|sendmail|weboptions)[ \t]*\("),
|
|
"high", "sandbox",
|
|
"The sandbox has no network at all, so this cannot run here. The code may still be "
|
|
"correct for the user's machine - say so rather than rewriting it.", 1),
|
|
Rule("shell-escape", _r(r"(?<![.\w])(system|unix|dos|winqueryreg)[ \t]*\(|^[ \t]*![A-Za-z_/.]"),
|
|
"medium", "sandbox",
|
|
"Shell access is not available in the sandbox. Remove it or note that it is untested "
|
|
"here."),
|
|
Rule("exit-quit", _r(r"^[ \t]*(exit|quit)[ \t]*(\(|;|$)"), "medium", "sandbox",
|
|
"`exit`/`quit` terminates the interpreter before results are captured. Remove it."),
|
|
Rule("arguments-block", _r(r"^[ \t]*arguments[ \t]*(\(|$)"), "high", "sandbox",
|
|
"An `arguments` validation block is valid MATLAB (R2019b+) but this sandbox's Octave "
|
|
"cannot parse it, so the run fails with a syntax error. That is a sandbox limit, not a "
|
|
"mistake: keep it for the user if they are on R2019b+, or use "
|
|
"`inputParser`/`validateattributes` to get something runnable here."),
|
|
)
|
|
|
|
SEMANTIC: tuple[Rule, ...] = (
|
|
Rule("double-quoted", _r(r'"'), "low", "semantic",
|
|
'In MATLAB R2017a+ "..." is a *string* object; in Octave it is a char array, and some '
|
|
"functions branch on that type. Use '...' for char unless you deliberately want a "
|
|
"string."),
|
|
Rule("unique-multi", _r(r"\[[^\]\n]*,[^\]\n]*\][ \t]*=[ \t]*unique[ \t]*\("), "medium", "semantic",
|
|
"The index outputs of `unique` have differed between MATLAB and Octave over first/last "
|
|
"occurrence. Verify the indices, or pass an explicit option."),
|
|
# Matches the surviving quote character, since bodies are blanked.
|
|
Rule("rand-seed", _r(r"(?<![.\w])(rand|randn)[ \t]*\([ \t]*['\"]"), "low", "semantic",
|
|
"Legacy generator seeding does not reproduce the same stream across MATLAB and Octave. "
|
|
"Use `rng(n)` in MATLAB if reproducibility matters."),
|
|
# Raw, because the pattern argument is a string body and every other
|
|
# rule sees it blanked. This one is here because it cost a whole task:
|
|
# a word-boundary count of "40" in a 100-word text returned 0, which
|
|
# is a plausible number, so nothing looked wrong until the model
|
|
# happened to re-check it with a plain pattern.
|
|
Rule("regexp-backspace",
|
|
_r(r"(?<![.\w])regexp(?:i|rep)?[ \t]*\([^\n]*?['\"][^'\"\n]*\\b"),
|
|
"high", "semantic",
|
|
"`\\b` in a regexp pattern is the BACKSPACE character, not a word boundary - in MATLAB "
|
|
"as well as in Octave, because it is the regexp engine that reads the escape. The "
|
|
"pattern then matches nothing and you get a wrong COUNT rather than an error. Use `\\<` "
|
|
"and `\\>` for word start and end, or a lookaround. Measured here: "
|
|
"`regexp('a b', '\\bb', 'match')` returns 0 matches.",
|
|
0, raw=True),
|
|
# The weight flag is documented backwards more often than it is used
|
|
# wrongly: a graded deliverable computed std(X,0,1), called it the
|
|
# population standard deviation in the docstring, the code comment and
|
|
# the usage notes, and told the user to swap to std(X,1,1) for N-1.
|
|
# Every clause inverted. Both dialects agree on the flag; say it once.
|
|
Rule("std-weight-flag", _r(r"(?<![.\w])(?:std|var)[ \t]*\([^()\n]*?,[ \t]*[01][ \t]*[,)]"),
|
|
"low", "semantic",
|
|
"In `std(x, w, dim)` / `var(x, w, dim)` the weight flag w=0 normalises by N-1 (the "
|
|
"sample statistic, the default) and w=1 by N (the population statistic) - in MATLAB "
|
|
"and Octave alike. If you document which one the code uses, check it against this "
|
|
"line: the flag is the thing that gets written backwards."),
|
|
Rule("idivide-negative", _r(r"(?<![.\w])idivide[ \t]*\("), "medium", "semantic",
|
|
"`idivide` with negative operands returned -4 for -7/2 in this sandbox, where MATLAB's "
|
|
"default 'fix' rounding gives -3. For scratch arithmetic prefer `floor`, `fix` or "
|
|
"`round` on doubles, and say which rounding you used."),
|
|
)
|
|
|
|
ALL_RULES: tuple[Rule, ...] = OCTAVE_ONLY + SANDBOX + SEMANTIC
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Finding:
|
|
rule: Rule
|
|
line: int
|
|
text: str
|
|
|
|
|
|
_ASSIGNED = re.compile(r"^[ \t]*(?:\[[^\]]*\][ \t]*=[^=]|([A-Za-z_]\w*)[ \t]*(?:\([^)]*\))?[ \t]*=(?!=))",
|
|
re.MULTILINE)
|
|
|
|
|
|
def _assigned_names(stripped: str) -> set[str]:
|
|
"""Identifiers the script assigns to, so builtin-name rules can skip them."""
|
|
names = set()
|
|
for m in _ASSIGNED.finditer(stripped):
|
|
if m.group(1):
|
|
names.add(m.group(1))
|
|
for m in re.finditer(r"^[ \t]*function\b[^\n]*?(?:=\s*)?([A-Za-z_]\w*)\s*\(", stripped, re.MULTILINE):
|
|
names.add(m.group(1))
|
|
return names
|
|
|
|
|
|
def lint(code: str) -> list[Finding]:
|
|
"""Run every rule against literal-stripped source, report original lines.
|
|
|
|
A rule marked `raw` is matched against the original instead. Line
|
|
numbers come out the same either way: strip_literals blanks in place,
|
|
so both strings have identical length and identical newline offsets.
|
|
"""
|
|
stripped = strip_literals(code)
|
|
lines = code.split("\n")
|
|
assigned = _assigned_names(stripped)
|
|
seen: set[tuple[str, int]] = set()
|
|
out: list[Finding] = []
|
|
|
|
for rule in ALL_RULES:
|
|
target = code if rule.raw else stripped
|
|
for m in rule.pattern.finditer(target):
|
|
if rule.name_group:
|
|
try:
|
|
name = m.group(rule.name_group)
|
|
except IndexError: # pragma: no cover
|
|
name = None
|
|
if name and name in assigned:
|
|
continue
|
|
line_no = target.count("\n", 0, m.start()) + 1
|
|
key = (rule.id, line_no)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
text = lines[line_no - 1].strip() if line_no <= len(lines) else ""
|
|
out.append(Finding(rule, line_no, text[:120]))
|
|
|
|
out.sort(key=lambda f: (f.line, f.rule.id))
|
|
return out
|
|
|
|
|
|
def is_function_file(code: str) -> str | None:
|
|
"""Return the function name if the source is a function-definition file.
|
|
|
|
A file whose first statement is `function` defines a function rather
|
|
than running anything, in both MATLAB and Octave. Executing it looks
|
|
like a bizarre "'a' undefined" error at the first use of a parameter,
|
|
which sends the model hunting for a bug that is not there.
|
|
"""
|
|
stripped = strip_literals(code)
|
|
for raw in stripped.split("\n"):
|
|
line = raw.strip()
|
|
if not line or line.startswith("%") or line.startswith("#"):
|
|
continue
|
|
m = re.match(r"^function\b(?:[^=\n]*=)?\s*([A-Za-z_]\w*)", line)
|
|
return m.group(1) if m else None
|
|
return None
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Script-local functions
|
|
#
|
|
# MATLAB (R2016b+) lets a script define its functions at the END and call
|
|
# them from anywhere above; Octave defines a function when execution
|
|
# reaches it, so the same script dies on "'foo' undefined". It is the one
|
|
# discrepancy that turns correct, ordinary MATLAB into a failed call - it
|
|
# cost m07 a whole tool call in the v3 round - and the model cannot fix it
|
|
# without writing something that is no longer the code the user asked for.
|
|
#
|
|
# So the tail is lifted out and written as separate .m files beside the
|
|
# script, which is where Octave will find them and how MATLAB behaves. The
|
|
# lines they came from are replaced with blank lines rather than deleted:
|
|
# an error two thirds of the way down still reports the line number the
|
|
# model wrote, and a diff of the file still lines up.
|
|
#
|
|
# Everything here bails rather than guesses. A file it does not fully
|
|
# understand is passed through untouched and fails the way it did before,
|
|
# which is a bad outcome; mangling a file that was about to work is a
|
|
# worse one.
|
|
# --------------------------------------------------------------------------
|
|
|
|
_MAX_LOCAL_FUNCTIONS = 40
|
|
_MAX_NAME = 63
|
|
|
|
# Statement-level block openers and closers. Matched at a statement
|
|
# boundary only - start of line, or after a `,`/`;` - so `x(end)` is not a
|
|
# block end and `numel(c)` is not a `for`.
|
|
_OPENERS = ("if", "for", "parfor", "while", "switch", "try", "do",
|
|
"function", "classdef", "unwind_protect", "spmd")
|
|
_CLOSERS = ("end", "endif", "endfor", "endparfor", "endwhile", "endswitch",
|
|
"end_try_catch", "endfunction", "end_unwind_protect", "until",
|
|
"endclassdef", "endspmd")
|
|
_BLOCK_TOKEN = re.compile(
|
|
r"(?:^|[;,])[ \t]*(" + "|".join(_OPENERS + _CLOSERS) + r")\b")
|
|
_FUNCTION_LINE = re.compile(r"^[ \t]*function\b(.*)$")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LocalFunction:
|
|
"""One function lifted out of a script."""
|
|
name: str
|
|
line: int # 1-based line it started on, so its file can be padded
|
|
text: str # the block, verbatim
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LocalFunctions:
|
|
"""The result of lifting a script's trailing function definitions out."""
|
|
script: str # same line count as the original
|
|
functions: tuple[LocalFunction, ...]
|
|
bailed: str | None = None # why nothing was lifted, for diagnosis
|
|
|
|
|
|
def _function_name(signature: str) -> str | None:
|
|
"""`[a, b] = foo(x)` -> foo. Returns None for anything unexpected."""
|
|
# The signature arrives with string and comment bodies already blanked,
|
|
# which also blanks a `...` continuation: a signature split across lines
|
|
# leaves its first line readable, and one that continues before naming
|
|
# the function (`function y = ...`) leaves nothing to read and is
|
|
# refused by the name pattern below.
|
|
sig = signature.split("%")[0].split("#")[0].strip()
|
|
head = sig.split("(", 1)[0]
|
|
if "=" in head:
|
|
head = head.split("=", 1)[1]
|
|
name = head.strip()
|
|
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]{0,%d}" % (_MAX_NAME - 1), name):
|
|
return None
|
|
if name in _KEYWORDS_BEFORE_STRING or name in ("main", "varargin", "varargout"):
|
|
return None
|
|
return name
|
|
|
|
|
|
def split_local_functions(code: str) -> LocalFunctions:
|
|
"""Lift a script's trailing `function` blocks into separate files.
|
|
|
|
Returns the script with those lines blanked and the functions in the
|
|
order they were written. `bailed` is set, and nothing is lifted, when
|
|
the file is not a plain script with a clean run of function
|
|
definitions at the end of it.
|
|
"""
|
|
stripped = strip_literals(code)
|
|
raw_lines = code.split("\n")
|
|
lines = stripped.split("\n")
|
|
|
|
stack: list[str] = []
|
|
first_fn: int | None = None
|
|
starts: list[int] = []
|
|
|
|
for idx, line in enumerate(lines):
|
|
body = line.split("%")[0].split("#")[0]
|
|
depth_at_start = len(stack)
|
|
top_level_function = False
|
|
|
|
for match in _BLOCK_TOKEN.finditer(body):
|
|
token = match.group(1)
|
|
if token in _OPENERS:
|
|
if token == "function":
|
|
# Top level means: nothing open, or only the previous
|
|
# unterminated function - Octave and MATLAB both allow
|
|
# a function file whose functions have no `end`.
|
|
if not stack or stack == ["function"]:
|
|
top_level_function = True
|
|
if stack == ["function"]:
|
|
stack.pop()
|
|
elif first_fn is not None:
|
|
return LocalFunctions(code, (), "a nested function")
|
|
if token == "classdef":
|
|
return LocalFunctions(code, (), "a classdef file")
|
|
stack.append(token)
|
|
else:
|
|
if not stack:
|
|
return LocalFunctions(code, (), "an unbalanced end")
|
|
stack.pop()
|
|
|
|
if top_level_function:
|
|
if first_fn is None:
|
|
first_fn = idx
|
|
starts.append(idx)
|
|
elif first_fn is not None and depth_at_start == 0 and body.strip():
|
|
# A statement at the top level after the definitions started -
|
|
# not a line of a function body, which sits at depth 1. MATLAB
|
|
# does not allow this either, so leave the file alone and let
|
|
# the run report whatever it reports.
|
|
return LocalFunctions(code, (), "code after the function definitions")
|
|
|
|
if first_fn is None:
|
|
return LocalFunctions(code, (), None)
|
|
if first_fn == 0 or not any(l.split("%")[0].split("#")[0].strip()
|
|
for l in lines[:first_fn]):
|
|
# Nothing above the first function: this is a function file, which
|
|
# is handled - and explained to the model - elsewhere.
|
|
return LocalFunctions(code, (), "a function file")
|
|
if len(starts) > _MAX_LOCAL_FUNCTIONS:
|
|
return LocalFunctions(code, (), f"more than {_MAX_LOCAL_FUNCTIONS} local functions")
|
|
|
|
bounds = list(zip(starts, starts[1:] + [len(lines)]))
|
|
functions: list[LocalFunction] = []
|
|
seen: set[str] = set()
|
|
for start, stop in bounds:
|
|
signature = _FUNCTION_LINE.match(lines[start])
|
|
name = _function_name(signature.group(1)) if signature else None
|
|
if name is None:
|
|
return LocalFunctions(code, (), "a function signature it could not read")
|
|
if name in seen:
|
|
return LocalFunctions(code, (), f"two functions named {name}")
|
|
seen.add(name)
|
|
functions.append(LocalFunction(
|
|
name, start + 1, "\n".join(raw_lines[start:stop]).rstrip() + "\n"))
|
|
|
|
script = raw_lines[:first_fn] + [""] * (len(raw_lines) - first_fn)
|
|
return LocalFunctions("\n".join(script), tuple(functions), None)
|