///|
fn run_script(ins : Instruction) -> String {
  if ins.arguments.has_prefix("[") {
    return ""
  }
  // A bare heredoc is a script. A heredoc redirected into cat is data.
  if ins.arguments.has_prefix("<<") && ins.heredocs.length() == 1 {
    return ins.heredocs[0].content
  }
  let mut source = ins.arguments
  while source.has_prefix("--") {
    let parts = source.split(" ").collect()
    if parts.length() < 2 {
      return ""
    }
    source = source[parts[0].length():].trim().to_owned()
  }
  source
}

///|
fn run_rules(ins : Instruction) -> Array[Diagnostic] {
  let source = run_script(ins)
  if source.is_empty() && !ins.arguments.has_prefix("[") {
    return []
  }
  let commands : Array[Array[ShellToken]] = try {
    if ins.arguments.has_prefix("[") {
      [
        ins.split_arguments().values.map(value => {
          kind: "word",
          value,
          raw: value,
          line: 1,
          quoted: true,
          dynamic: false,
        }),
      ]
    } else {
      shell_commands(source)
    }
  } catch {
    _ =>
      return [
        {
          line: ins.line,
          code: "ML1011",
          message: "Invalid shell quoting or substitution",
        },
      ]
  }
  let out : Array[Diagnostic] = []
  fn emit(code : String, message : String) -> Unit {
    if !out.iter().any(d => d.code == code) {
      out.push({ line: ins.line, code, message, })
    }
  }
  for index, tokens in commands {
    if tokens.is_empty() {
      continue
    }
    if tokens[0].dynamic {
      continue
    }
    let name = executable_name(tokens[0].value)
    let args = tokens[1:].map(t => t.value)
    let remaining = commands[index + 1:]
    fn cleaned(manager : String, path : String) -> Bool {
      remaining
      .iter()
      .any(cmd => {
        let values = cmd.map(t => t.value)
        if manager == "apt" {
          executable_name(values[0]) == "rm" &&
          values.iter().any(s => s.has_prefix(path))
        } else {
          executable_name(values[0]) == manager &&
          values.contains("clean") &&
          (path == "" || values.contains(path))
        }
      })
    }
    if name == "sudo" {
      emit("DL3004", "Avoid sudo; use USER for privilege changes")
    }
    if name == "cd" {
      emit("DL3003", "Use WORKDIR instead of RUN cd")
    }
    if ["ssh", "service", "shutdown", "reboot", "mount", "su"].contains(name) {
      emit(
        "DL3001", "This command is usually inappropriate in a container build",
      )
    }
    if name == "apt" {
      emit("DL3027", "Use apt-get or apt-cache instead of interactive apt")
    }
    if name == "apt-get" &&
      (args.contains("install") || args.contains("update")) {
      if args.contains("install") {
        let packages = package_operands(args, "install")
        if packages
          .iter()
          .any(p => {
            !p.contains("=") && !p.has_prefix("/") && !p.has_prefix("./")
          }) {
          emit("DL3008", "Pin apt-get package versions with package=version")
        }
        if !flag_present(args, ["-y", "--yes", "--assume-yes"]) &&
          !args.contains("-qq") {
          emit("DL3014", "Use a non-interactive apt-get install")
        }
        if !args.contains("--no-install-recommends") {
          emit("DL3015", "Avoid installing recommended packages")
        }
      }
      if args.contains("update") && !cleaned("apt", "/var/lib/apt/lists/") {
        emit("DL3009", "Remove apt lists in the same RUN after apt-get update")
      }
    }
    if name == "apk" && args.contains("add") {
      if package_operands(args, "add")
        .iter()
        .any(p => {
          !p.contains("=") &&
          !p.contains("~") &&
          !p.has_prefix("/") &&
          !p.has_prefix("./")
        }) {
        emit("DL3018", "Pin apk package versions")
      }
      if !args.contains("--no-cache") {
        emit("DL3019", "Use apk add --no-cache")
      }
    }
    if ["yum", "dnf", "zypper"].contains(name) &&
      (args.contains("install") || (name == "zypper" && args.contains("in"))) {
      let verb = if args.contains("install") { "install" } else { "in" }
      let zypper = name == "zypper"
      if !flag_present(
          args,
          if zypper {
            ["-n", "--non-interactive"]
          } else {
            ["-y", "--assumeyes"]
          },
        ) {
        emit(
          if name == "yum" {
            "DL3030"
          } else if name == "dnf" {
            "DL3038"
          } else {
            "DL3034"
          },
          "Use non-interactive package installation",
        )
      }
      if !cleaned(name, if zypper { "" } else { "all" }) {
        emit(
          if name == "yum" {
            "DL3032"
          } else if name == "dnf" {
            "DL3040"
          } else {
            "DL3036"
          },
          "Clean the package cache in the same RUN after installation",
        )
      }
      if package_operands(args, verb)
        .iter()
        .any(p => {
          !p.contains("=") &&
          !p.split("-").collect()[1:]
          .iter()
          .any(part => {
            !part.is_empty() &&
            part.to_owned().to_array()[0] >= '0' &&
            part.to_owned().to_array()[0] <= '9'
          }) &&
          !p.has_suffix(".rpm") &&
          !p.contains("$")
        }) {
        emit(
          if name == "yum" {
            "DL3033"
          } else if name == "dnf" {
            "DL3041"
          } else {
            "DL3037"
          },
          "Pin package versions",
        )
      }
    }
    if name == "zypper" &&
      (args.contains("dup") || args.contains("dist-upgrade")) {
      emit("DL3035", "Avoid zypper dist-upgrade")
    }
    let is_pip = name == "pip" ||
      name == "pip3" ||
      (
        (name == "python" || name == "python3") &&
        args.length() >= 2 &&
        args[0] == "-m" &&
        args[1] == "pip"
      )
    if is_pip && args.contains("install") {
      if package_operands(args, "install")
        .iter()
        .any(p => {
          !p.contains("==") &&
          !p.contains("~=") &&
          !p.has_prefix("/") &&
          !p.has_prefix(".") &&
          !p.contains("://") &&
          !p.has_suffix(".whl")
        }) {
        emit("DL3013", "Pin pip package versions or use a requirements file")
      }
      if !args.contains("--no-cache-dir") {
        emit("DL3042", "Disable pip's download cache")
      }
    }
    if name == "npm" && (args.contains("install") || args.contains("i")) {
      let packages = package_operands(
        args,
        if args.contains("install") {
          "install"
        } else {
          "i"
        },
      )
      if packages
        .iter()
        .any(p => {
          let p = if p.has_prefix("@") { p[1:].to_owned() } else { p }
          !p.contains("@") &&
          !p.contains("#") &&
          !p.contains("://") &&
          !p.has_prefix(".") &&
          !p.has_prefix("/")
        }) {
        emit("DL3016", "Pin npm package versions")
      }
    }
    if name == "gem" &&
      args.contains("install") &&
      !flag_present(args, ["-v", "--version"]) &&
      package_operands(args, "install").iter().any(p => !p.contains(":")) {
      emit("DL3028", "Pin gem package versions")
    }
    if name == "go" &&
      args.contains("install") &&
      package_operands(args, "install")
      .iter()
      .any(p => !p.contains("@") && !p.has_prefix(".") && !p.has_prefix("/")) {
      emit("DL3062", "Pin go install package versions")
    }
    if name == "wget" &&
      !flag_present(args, ["-q", "--quiet", "--progress", "-nv", "--no-verbose"]) {
      emit("DL3047", "Use wget progress settings suitable for build logs")
    }
    if name == "ln" && args.contains("/bin/sh") {
      emit("DL4005", "Use SHELL instead of replacing /bin/sh")
    }
  }
  out
}