Basic Scripting in Go

গো (Go) তে স্ক্রিপ্ট লিখে কিভাবে ছোটখাটো কাজ অটোমেট করা যায়

আজকে একটা প্রজেক্টে কাজ করতে গিয়ে খুব স্পেসিফিক একটা সমস্যায় পড়লাম। দ্রুপাল কোরের একটা আপডেটের জন্য প্রজেক্টের কনফিগ ফাইলগুলোতে git diff করে দেখি প্রায় ৪৫০র কাছাকাছি আপডেট হয়েছে! কিছু ইনভেস্টিগেশন করে জানা গেলো অধিকাংশ চেঞ্জই লাইন-পজিশন এর। মানে, ২ নাম্বার লাইন ৪ এ আসছে, ৩ নাম্বার ১ নাম্বারে গেছে, এমন। অনেকটা নিচের diff টার মত।

diff --git a/A.txt b/A.txt
--- a/A.txt
+++ b/A.txt
@@ -1,5 +1,5 @@
Hello World!
-I am excited about programming.
-
Thanks!
+
+I am excited about programming.

এখন আমাকে প্রত্যেকটা ফাইলে গিয়ে গিয়ে দেখতে হবে কোনটার পজিশন আর কোনটায় আসলেই চেঞ্জ হয়েছে। খুবই বোরিং (প্রায় অসম্ভব!) একটা কাজ। তাই অলস হিসাবে আমার সুনাম ধরে রাখতে ভাবলাম গো দিয়ে একটা স্ক্রিপ্ট লিখে কাজটা অটোমেট করা যায় কিনা? কিছু ফান্ডামেন্টালও ক্লিয়ার হবে, আর সময়ও বাঁচবে (ডক ঘাটতে ঘাটতে সারাদিন গেছে!)

কিভাবে করা যেতে পারে ?

প্রথমে ল্যাঙ্গুয়েজ স্পেসিফিক কিছু না ভেবে একটা টাস্ক লিস্ট বানালাম। তারপরে গিয়ে নাহয় লিস্টের একেকটা আইটেম গো তে কিভাবে ইমপ্লিমেন্ট করা যায় সেটা জানা লাগবে।

  • একটা নির্দিষ্ট ফোল্ডারে গিয়ে git diff এই কমান্ডটা চালানো লাগবে। তাহলে আমরা কনসোলে বা stdout এ যে আউটপুট পাবো সেটার উপরে আমাদের বাকি কাজ।

  • stdout এর ভ্যালুকে স্ট্রিং এ কনভার্ট করে সেটার উপরে regex চালিয়ে আমাদের খুঁজে বের করতে হবে প্রত্যেকটা ফাইলের জন্য কি কি চেঞ্জ আসছে।

  • চেঞ্জগুলো যদি শুধু পজিশনাল না হয়, তাহলে ওই ভ্যালুকে একটা JSON ফাইলে রাইট করতে হবে, যেখানে প্রত্যেকটা ইনপুট হবে এমন,

[
  "filename": {
    "+": [added changes],
    "-": [deleted changes]
  }
]

কিভাবে করার চেষ্টা করেছি

উপরের স্টেপ গুলো একে একে ইমপ্লিমেন্ট করা যাক।

git diff এর আউটপুট স্ট্রিং এ কনভার্ট করে স্টোর করা

আমার প্রথম কাজ হল, যেখান থেকেই স্ক্রিপ্টটা রান করা হোক না কেন, সেটাতে একটা আর্গুমেন্ট পাঠানোর সুযোগ থাকবে। মানে আমি যদি লিখি, go run diff-to-json.go ~/Documents, তাহলে প্রথমে প্রোগ্রামটা Documents ফোল্ডারে যাবে এবং সেখানে গিয়ে git diff রান করবে। গো তে খুব সহজেই কমান্ডের আর্গুমেন্ট ক্যাচ করা যায় os.Args দিয়ে।

  // determine folder location from cmd arguments
  loc := "."
  if len(os.Args) > 1 && os.Args[1] != "" {
    loc = os.Args[1]
  }

আচ্ছা, আমি লোকেশন পেয়ে গেলাম, কোথায় গিয়ে আমাকে git diff চালাতে হবে। এবার কাজ হল git diff এর আউটপুটকে স্ট্রিং এ কনভার্ট করে স্টোর করা।

  // generate diff as []byte
  diff, err := generateDiff(loc)
  if err != nil {
    panic("Couldn't generate diff. Check the folder path.")
  }

  // create slice of lines from diff string
  lines := strings.Split(string(diff), "\n")
  if len(lines) == 0 {
    panic("No diff found!")
  }

তাহলে, lines স্লাইসের মধ্যে আমাদের সব diff লাইন বাই লাইন চলে আসলো। generateDiff ফাংশনের মধ্যে কি হচ্ছে? শুধু দুইটা কমান্ড চালাচ্ছি os/exec প্যাকেজ এর ফাংশন দিয়ে।

// generate git diff for loc folder
func generateDiff(loc string) ([]byte, error) {
 args := []string{"cd", loc, "&&", "git", "diff"}
 cmd := exec.Command("/bin/bash", "-c", strings.Join(args, " "))
 return cmd.CombinedOutput()
}

সিনট্যাক্সের বর্ণনায় আর গেলাম না, ডকে খুব সুন্দর করে বলা আছে এমনিতেও।

regex দিয়ে আমাদের প্রয়োজনীয় লাইনগুলো খুঁজে বের করা

আমাদের কাছে যেহেতু এখন lines স্লাইসে প্রত্যেকটা লাইন আলাদা করে সাজানো আছে, আমরা সহজেই লুপ চালিয়ে আমাদের দরকারি লাইনগুলো আলাদা করে ফেলতে পারি।

  allDiffs := make(map[string]map[string][]string)
  selectedDiffs := make(map[string]map[string][]string)

  currentfilepath := ""

  for _, line := range lines {
  // select only if changed line or filepath line
  // -    negate: true
  // +    negate: false
  // --- a/config/drupal/default/addtoany.settings.yml
  // +++ a/config/drupal/default/addtoany.settings.yml
  matched, err := regexp.MatchString("^[+-]([+-][+-])?[a-zA-Z _].*", line)
  if err != nil {
    panic(err)
  }

  if matched {
    // select only filepath line
    // --- a/config/drupal/default/addtoany.settings.yml
    // +++ a/config/drupal/default/addtoany.settings.yml
    if strings.HasPrefix(line, "+++") || strings.HasPrefix(line, "---") {

      // extract filepath from full path
      // config/drupal/default/addtoany.settings.yml
      fullpath := regexp.MustCompile(` [ab]/`).Split(line, -1)
      filepath := fullpath[len(fullpath)-1]

      // init entry against the filepath
      if _, ok := allDiffs[filepath]; !ok {
        allDiffs[filepath] = make(map[string][]string)
        currentfilepath = filepath
      }
    } else {
      // if changed line, add according to add/delete sign
      if string(line[0]) == "+" {
        allDiffs[currentfilepath]["+"] = append(allDiffs[currentfilepath]["+"], line[1:])
      }
      if string(line[0]) == "-" {
        allDiffs[currentfilepath]["-"] = append(allDiffs[currentfilepath]["-"], line[1:])
      }
    }
  }
}

// after building map from diff, filter the map to get
// diff which are not positional changes. Meaning, if a
// statement was on line 10, and now on line 15, the diff
// for the file will be excluded.
for key, val := range allDiffs {
  added := val["+"]
  deleted := val["-"]

  for _, va := range added {
    found := false
    for _, vd := range deleted {
      if va == vd {
        found = true
        break
      }
    }
    if found == false {
      if _, ok := selectedDiffs[key]; !ok {
        selectedDiffs[key] = map[string][]string{
          "+": val["+"],
          "-": val["-"],
        }
      }
    }
  }
}

// write selected diff lines to JSON
writeToJSON(loc, selectedDiffs)

এখানে আমি নেস্টেড লুপ চালিয়ে প্রথমে ম্যাপ বানিয়েছি স্লাইস থেকে। তার পরের নেস্টেড লুপে সেই ম্যাপ ফিল্টার করে বের করেছি কোনোগুলো চেঞ্জ শুধু পজিশনাল না। আরো এফিসিয়েন্টলি এটা করা যায়, কিন্তু অপ্টিমাইজ করার সময় কই ?!!

শেষমেষ, এখন আমাদের কাছে একটা ম্যাপ selectedDiffs আছে যার প্রতেকটা কি আর ভ্যালু একদম আমাদের JSON এর স্ট্রাকচারের মত! সেটাকে .json ফাইলে কনভার্ট করতে আমরা কল করেছি writeToJSON ফাংশনটা।

ফাইলে JSON রাইট করা

গো এর একটা জিনিস আমার খুব ভালো লেগেছে, তা হল এর সিমপ্লিসিটি। লাইব্রেরী ফাংশনগুলো সিম্পল আর এক্সপ্রেসিভ, ফলে খুব সহজেই অনেক ভারী কাজ করা যায়!

// writes selected diffs to a json file in loc folder
func writeToJSON(loc string, selectedDiffs map[string]map[string][]string) {
  // create or overwrite previous file
  file, err := os.OpenFile(loc+"/result.json", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0755)
  if err != nil {
    panic(err)
  }

  // close file when writeToJSON ends
  defer file.Close()

  // convert map to JSON
  jsonDiff, err := json.Marshal(selectedDiffs)
  if err != nil {
    panic(err)
  }

  // write to file
  if _, err := file.WriteString(string(jsonDiff)); err != nil {
    panic(err)
  }
}

os আর json প্যাকেজ দিয়ে একটা ফাইল ওপেন করে তাতে কিছু রাইট করা হচ্ছে । Marshal ব্যাপারটা আমি নিজেও ক্লিয়ারলি বুঝিনি ঠিক, কিন্তু কাজ চালানোর মত নলেজ ডকেই আছে!

ফলাফল

সব ঠিকঠাক চললে আর্গুমেন্টে যে ফোল্ডার দেয়া হয়েছে সেখানে results.json নামে একটা ফাইল তৈরি হবে, শুরুতে দেয়া স্ট্রাকচারের মত। পুরো কোড পাওয়া যাবে এই ফাইলে